feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { fetchColonyOverview } from "./field-api";
|
||||
import { listPacks } from "./offline-pack";
|
||||
import { offlinePack, packColony, storedPack } from "./offline-pack.fixtures";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
vi.mock("./offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
const PACK = offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
colonies: [packColony("colony-1", "A1"), packColony("colony-2", "A2")],
|
||||
});
|
||||
|
||||
describe("fetchColonyOverview", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([storedPack(PACK)]);
|
||||
});
|
||||
|
||||
it("asks the server", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(PACK.overview);
|
||||
|
||||
expect(await fetchColonyOverview("apiary-1", "user-1")).toEqual(PACK.overview);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-1/colony-overview");
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers from the loaded pack without network", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
const overview = await fetchColonyOverview("apiary-1", "user-1");
|
||||
|
||||
expect(overview.rows.map((row) => row.code)).toEqual(["A1", "A2"]);
|
||||
expect(listPacks).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("fails without network when this apiary was not loaded", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-2", "user-1")).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
it("never replaces a refusal with pack data", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new ApiError(404, "Apiary not found"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-1", "user-1")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only asks the server when nobody is signed in", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-1", null)).rejects.toBeInstanceOf(TypeError);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,26 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
import type { ColonyOverview } from "./field-types";
|
||||
|
||||
export function fetchColonyOverview(apiaryId: string): Promise<ColonyOverview> {
|
||||
return apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
|
||||
/**
|
||||
* The apiary's colonies with their traffic-light inputs. For a signed-in
|
||||
* user without network it answers from the apiary's offline pack, if loaded
|
||||
* (same shape: the pack carries the overview as it was when loaded).
|
||||
*/
|
||||
export function fetchColonyOverview(
|
||||
apiaryId: string,
|
||||
ownerUserId: string | null = null,
|
||||
): Promise<ColonyOverview> {
|
||||
const fromServer = () =>
|
||||
apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
|
||||
if (!ownerUserId) {
|
||||
return fromServer();
|
||||
}
|
||||
return withPackFallback(
|
||||
fromServer,
|
||||
(packs) => packs.find((stored) => stored.apiaryId === apiaryId)?.pack.overview ?? null,
|
||||
ownerUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Query key of the apiary page's colony overview. */
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Test data for offline packs - shared by the specs, never imported by app code.
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
import type { ColonyOverviewRow, OfflinePackV1 } from "./field-types";
|
||||
|
||||
export const MINUTE_MS = 60 * 1000;
|
||||
export const HOUR_MS = 60 * MINUTE_MS;
|
||||
export const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
type PackColony = OfflinePackV1["colonies"][number];
|
||||
type PackInspection = NonNullable<OfflinePackV1["inspections"]>[number];
|
||||
type PackTask = NonNullable<OfflinePackV1["tasks"]>[number];
|
||||
type PackQueen = NonNullable<OfflinePackV1["queens"]>[number];
|
||||
|
||||
export function packColony(id: string, code: string, overrides: Partial<PackColony> = {}): PackColony {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
apiaryId: "apiary-1",
|
||||
status: "ACTIVE",
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
hiveType: "Zander",
|
||||
strength: 8,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "GOOD",
|
||||
healthStatus: "GOOD",
|
||||
currentQueenId: null,
|
||||
notes: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function overviewRow(colony: PackColony, overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
|
||||
return {
|
||||
id: colony.id,
|
||||
code: colony.code,
|
||||
status: colony.status,
|
||||
strength: colony.strength,
|
||||
broodStatus: colony.broodStatus,
|
||||
feedStatus: colony.feedStatus,
|
||||
healthStatus: colony.healthStatus,
|
||||
lastInspectionDate: new Date().toISOString(),
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packInspection(
|
||||
id: string,
|
||||
colonyId: string,
|
||||
date: string,
|
||||
overrides: Partial<PackInspection> = {},
|
||||
): PackInspection {
|
||||
return {
|
||||
id,
|
||||
colonyId,
|
||||
date,
|
||||
weather: null,
|
||||
temperature: null,
|
||||
colonyStrength: null,
|
||||
broodAmount: null,
|
||||
broodFrames: null,
|
||||
broodPattern: null,
|
||||
openBroodPresent: null,
|
||||
cappedBroodPresent: null,
|
||||
eggsPresent: null,
|
||||
queenSeen: null,
|
||||
queenCellsPresent: null,
|
||||
swarmMood: null,
|
||||
gentleness: null,
|
||||
combBehavior: null,
|
||||
feedStatus: null,
|
||||
honeySuperStatus: null,
|
||||
healthIssues: null,
|
||||
varroaIssues: null,
|
||||
measures: null,
|
||||
notes: null,
|
||||
createdAt: date,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packTask(id: string, title: string, overrides: Partial<PackTask> = {}): PackTask {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description: null,
|
||||
dueDate: null,
|
||||
priority: "NORMAL",
|
||||
status: "OPEN",
|
||||
relatedType: "COLONY",
|
||||
relatedId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packQueen(id: string, code: string, overrides: Partial<PackQueen> = {}): PackQueen {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
hatchYear: 2025,
|
||||
breed: "Carnica",
|
||||
markingColor: "Rot",
|
||||
markingNumber: "12",
|
||||
status: "ACTIVE",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface PackOptions {
|
||||
apiaryId?: string;
|
||||
apiaryName?: string;
|
||||
colonies?: PackColony[];
|
||||
queens?: OfflinePackV1["queens"];
|
||||
inspections?: OfflinePackV1["inspections"];
|
||||
tasks?: OfflinePackV1["tasks"];
|
||||
inspectionsVisible?: boolean;
|
||||
}
|
||||
|
||||
/** A pack as the server sends it: colonies, their overview rows and the sections. */
|
||||
export function offlinePack({
|
||||
apiaryId = "apiary-1",
|
||||
apiaryName = "Obstwiese",
|
||||
colonies = [packColony("colony-1", "A1", { apiaryId })],
|
||||
queens = [],
|
||||
inspections = [],
|
||||
tasks = [],
|
||||
inspectionsVisible = true,
|
||||
}: PackOptions = {}): OfflinePackV1 {
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: "2026-10-13T08:00:00.000Z",
|
||||
apiary: { id: apiaryId, name: apiaryName, location: "Hinterm Hof", status: "ACTIVE" },
|
||||
overview: {
|
||||
apiaryId,
|
||||
inspectionsVisible,
|
||||
rows: colonies.map((colony) => overviewRow(colony)),
|
||||
},
|
||||
colonies,
|
||||
queens,
|
||||
inspections,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export function storedPack(
|
||||
pack: OfflinePackV1,
|
||||
{ ownerUserId = "user-1", savedAt = new Date().toISOString() } = {},
|
||||
): StoredPack {
|
||||
return { apiaryId: pack.apiary.id, ownerUserId, savedAt, pack };
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import {
|
||||
PACK_AUTO_REFRESH_AFTER_MS,
|
||||
PACK_MAX_AGE_MS,
|
||||
PACK_STALE_MS,
|
||||
deleteAllPacks,
|
||||
findPackForColony,
|
||||
getPack,
|
||||
isPackStale,
|
||||
listPacks,
|
||||
loadPack,
|
||||
packAgeText,
|
||||
purgeOldPacks,
|
||||
refreshPacksAfterSync,
|
||||
} from "./offline-pack";
|
||||
import {
|
||||
DAY_MS,
|
||||
HOUR_MS,
|
||||
MINUTE_MS,
|
||||
offlinePack,
|
||||
packColony,
|
||||
storedPack,
|
||||
} from "./offline-pack.fixtures";
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const NOW = Date.parse("2026-10-13T12:00:00.000Z");
|
||||
|
||||
function savedAgo(ms: number): string {
|
||||
return new Date(NOW - ms).toISOString();
|
||||
}
|
||||
|
||||
async function putPacks(...packs: StoredPack[]): Promise<void> {
|
||||
const db = await getDb();
|
||||
await Promise.all(packs.map((pack) => db.put("offline-packs", pack)));
|
||||
}
|
||||
|
||||
async function storedApiaryIds(): Promise<string[]> {
|
||||
const db = await getDb();
|
||||
return (await db.getAllKeys("offline-packs")).sort();
|
||||
}
|
||||
|
||||
describe("offline packs", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
await deleteAllPacks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stores the pack with owner and time", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
const pack = offlinePack({ apiaryId: "apiary-1" });
|
||||
vi.mocked(apiFetch).mockResolvedValue(pack);
|
||||
|
||||
const stored = await loadPack("apiary-1", "user-1");
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-1/offline-pack");
|
||||
expect(stored).toEqual({
|
||||
apiaryId: "apiary-1",
|
||||
ownerUserId: "user-1",
|
||||
savedAt: new Date(NOW).toISOString(),
|
||||
pack,
|
||||
});
|
||||
expect(await getPack("apiary-1")).toEqual(stored);
|
||||
});
|
||||
|
||||
it("never stores coordinates or an address, even if the server sent them", async () => {
|
||||
const pack = offlinePack({ apiaryId: "apiary-1" });
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
...pack,
|
||||
apiary: { ...pack.apiary, latitude: 52.1, longitude: 13.4, address: "Feldweg 3", notes: "Schlüssel unterm Stein" },
|
||||
});
|
||||
|
||||
await loadPack("apiary-1", "user-1");
|
||||
|
||||
const kept = await getPack("apiary-1");
|
||||
expect(Object.keys(kept?.pack.apiary ?? {}).sort()).toEqual(["id", "location", "name", "status"]);
|
||||
});
|
||||
|
||||
it("refuses a pack it does not understand instead of storing it", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ...offlinePack(), version: 2 });
|
||||
|
||||
await expect(loadPack("apiary-1", "user-1")).rejects.toThrow();
|
||||
expect(await getPack("apiary-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("encodes the apiary id in the request", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "a/b" }));
|
||||
|
||||
await loadPack("a/b", "user-1");
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/a%2Fb/offline-pack");
|
||||
});
|
||||
|
||||
it("findPackForColony", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1", colonies: [packColony("colony-1", "A1")] })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("colony-2", "B1")] })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-3", colonies: [packColony("colony-3", "C1")] }), {
|
||||
ownerUserId: "user-2",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await findPackForColony("colony-2", "user-1"))?.apiaryId).toBe("apiary-2");
|
||||
// Another account's pack is never handed out.
|
||||
expect(await findPackForColony("colony-3", "user-1")).toBeNull();
|
||||
expect(await findPackForColony("colony-unknown", "user-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("findPackForColony prefers the newest pack when a colony moved between loaded apiaries", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old", colonies: [packColony("colony-1", "A1")] }), {
|
||||
savedAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(),
|
||||
}),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-new", colonies: [packColony("colony-1", "A1")] }), {
|
||||
savedAt: new Date(Date.now() - HOUR_MS).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await findPackForColony("colony-1", "user-1"))?.apiaryId).toBe("apiary-new");
|
||||
});
|
||||
|
||||
it("lists only the owner's packs", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
|
||||
expect((await listPacks("user-1")).map((pack) => pack.apiaryId)).toEqual(["apiary-1"]);
|
||||
});
|
||||
|
||||
it("never hands out a pack older than 14 days, even before it is purged", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), {
|
||||
savedAt: new Date(Date.now() - PACK_MAX_AGE_MS - MINUTE_MS).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await listPacks("user-1")).toEqual([]);
|
||||
expect(await getPack("apiary-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("purges packs older than 14 days", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(15 * DAY_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-edge" }), { savedAt: savedAgo(PACK_MAX_AGE_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(13 * DAY_MS) }),
|
||||
);
|
||||
|
||||
const purged = await purgeOldPacks(NOW);
|
||||
|
||||
expect(purged).toBe(1);
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-edge", "apiary-young"]);
|
||||
});
|
||||
|
||||
it("stale after 24 h", () => {
|
||||
const pack = offlinePack();
|
||||
|
||||
expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS - 1) }), NOW)).toBe(false);
|
||||
expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS) }), NOW)).toBe(true);
|
||||
expect(isPackStale(storedPack(pack, { savedAt: "kaputt" }), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes every pack of every account", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
|
||||
await deleteAllPacks();
|
||||
|
||||
expect(await storedApiaryIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("says how long ago a pack was loaded", () => {
|
||||
const at = (ms: number) => packAgeText(savedAgo(ms), NOW);
|
||||
|
||||
expect(at(20 * 1000)).toBe("vor weniger als einer Minute");
|
||||
expect(at(MINUTE_MS)).toBe("vor 1 Minute");
|
||||
expect(at(5 * MINUTE_MS)).toBe("vor 5 Minuten");
|
||||
expect(at(HOUR_MS)).toBe("vor 1 Stunde");
|
||||
expect(at(3 * HOUR_MS + 10 * MINUTE_MS)).toBe("vor 3 Stunden");
|
||||
expect(at(DAY_MS)).toBe("vor 1 Tag");
|
||||
expect(at(2 * DAY_MS + HOUR_MS)).toBe("vor 2 Tagen");
|
||||
});
|
||||
|
||||
describe("refreshPacksAfterSync", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
it("quietly reloads the owner's packs older than 1 h and leaves younger ones alone", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old" }), {
|
||||
savedAt: savedAgo(PACK_AUTO_REFRESH_AFTER_MS + MINUTE_MS),
|
||||
}),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(30 * MINUTE_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-foreign" }), {
|
||||
ownerUserId: "user-2",
|
||||
savedAt: savedAgo(5 * HOUR_MS),
|
||||
}),
|
||||
);
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-old", apiaryName: "Neu" }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-old/offline-pack");
|
||||
const refreshed = await getPack("apiary-old");
|
||||
expect(refreshed?.pack.apiary.name).toBe("Neu");
|
||||
expect(refreshed?.savedAt).toBe(new Date(NOW).toISOString());
|
||||
});
|
||||
|
||||
it("after a rejection reloads a 10-min-old pack holding the colony, not a 2-min-old one", async () => {
|
||||
await putPacks(
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-ten", colonies: [packColony("colony-3", "V-03")] }),
|
||||
{ savedAt: savedAgo(10 * MINUTE_MS) },
|
||||
),
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-two", colonies: [packColony("colony-3", "V-03")] }),
|
||||
{ savedAt: savedAgo(2 * MINUTE_MS) },
|
||||
),
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-other", colonies: [packColony("colony-9", "X-09")] }),
|
||||
{ savedAt: savedAgo(10 * MINUTE_MS) },
|
||||
),
|
||||
);
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-ten", colonies: [] }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW, undefined, {
|
||||
force: true,
|
||||
colonyIds: ["colony-3"],
|
||||
});
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-ten/offline-pack");
|
||||
// The colony ended on the server: the reloaded pack no longer offers it.
|
||||
expect((await getPack("apiary-ten"))?.pack.colonies).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a pack the user may no longer read or whose apiary is gone", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-gone" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-forbidden" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-broken" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
vi.mocked(apiFetch).mockImplementation(async (path: string) => {
|
||||
if (path.includes("apiary-gone")) throw new ApiError(404, "Apiary not found");
|
||||
if (path.includes("apiary-forbidden")) throw new ApiError(403, "Forbidden");
|
||||
throw new ApiError(500, "Internal server error");
|
||||
});
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
// A server failure keeps the pack: it is still the best the device knows.
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-broken"]);
|
||||
});
|
||||
|
||||
it("stops at the first network error and never throws", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(refreshPacksAfterSync("user-1", NOW)).resolves.toBeUndefined();
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-1", "apiary-2"]);
|
||||
});
|
||||
|
||||
it("stops once the sync run is cancelled", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
let cancelled = false;
|
||||
vi.mocked(apiFetch).mockImplementation(async (path: string) => {
|
||||
cancelled = true;
|
||||
return offlinePack({ apiaryId: path.split("/")[2] });
|
||||
});
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW, () => cancelled);
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("purges packs older than 14 days on the way", async () => {
|
||||
await putPacks(storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(20 * DAY_MS) }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
expect(await storedApiaryIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { getConnectivity } from "@/lib/offline/connectivity";
|
||||
import { getDb, type StoredPack } from "@/lib/offline/db";
|
||||
import type { OfflinePackV1 } from "./field-types";
|
||||
|
||||
/** From this age on a pack is marked as old: "bei Netz neu laden". */
|
||||
export const PACK_STALE_MS = 24 * 60 * 60 * 1000;
|
||||
/** Older packs are deleted - and never shown in the meantime. */
|
||||
export const PACK_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
/** After a sync, packs older than this are reloaded quietly. */
|
||||
export const PACK_AUTO_REFRESH_AFTER_MS = 60 * 60 * 1000;
|
||||
/** After a rejection, a pack listing the colony is reloaded from this age on. */
|
||||
export const PACK_FORCED_REFRESH_AFTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Prefix of every query that reads packs (this device's storage only). */
|
||||
export const OFFLINE_PACKS_QUERY_KEY = ["offline-packs"] as const;
|
||||
|
||||
export type PackColony = OfflinePackV1["colonies"][number];
|
||||
export type PackInspection = NonNullable<OfflinePackV1["inspections"]>[number];
|
||||
export type PackTask = NonNullable<OfflinePackV1["tasks"]>[number];
|
||||
export type PackQueen = NonNullable<OfflinePackV1["queens"]>[number];
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
function ageOf(pack: StoredPack, now: number): number {
|
||||
// An unreadable time counts as infinitely old: refreshed, marked, purged.
|
||||
const age = now - Date.parse(pack.savedAt);
|
||||
return Number.isNaN(age) ? Number.POSITIVE_INFINITY : age;
|
||||
}
|
||||
|
||||
function isExpired(pack: StoredPack, now: number): boolean {
|
||||
return ageOf(pack, now) > PACK_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
export function isPackStale(pack: StoredPack, now: number): boolean {
|
||||
return ageOf(pack, now) >= PACK_STALE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly the apiary fields the offline mode shows. Apiary locations are
|
||||
* theft-sensitive: should the server ever send coordinates, an address or
|
||||
* notes in a pack, they never reach the stored pack (IndexedDB). The service
|
||||
* worker separately never keeps the apiary answers that carry them
|
||||
* (/apiaries, /apiaries/:id - see public/sw-routes.js).
|
||||
*/
|
||||
function withoutApiaryLocation(pack: OfflinePackV1): OfflinePackV1 {
|
||||
const { id, name, location, status } = pack.apiary;
|
||||
return { ...pack, apiary: { id, name, location, status } };
|
||||
}
|
||||
|
||||
function isPackFor(value: unknown, apiaryId: string): value is OfflinePackV1 {
|
||||
const pack = value as Partial<OfflinePackV1> | null;
|
||||
return pack?.version === 1 && pack.apiary?.id === apiaryId && Array.isArray(pack.colonies);
|
||||
}
|
||||
|
||||
export async function fetchOfflinePack(apiaryId: string): Promise<OfflinePackV1> {
|
||||
const pack: unknown = await apiFetch<unknown>(
|
||||
`/apiaries/${encodeURIComponent(apiaryId)}/offline-pack`,
|
||||
);
|
||||
if (!isPackFor(pack, apiaryId)) {
|
||||
throw new Error("Unbekanntes Offline-Paket – bitte die App aktualisieren.");
|
||||
}
|
||||
return pack;
|
||||
}
|
||||
|
||||
/** Fetches the apiary's pack and keeps it on this device for `ownerUserId`. */
|
||||
export async function loadPack(apiaryId: string, ownerUserId: string): Promise<StoredPack> {
|
||||
const pack = withoutApiaryLocation(await fetchOfflinePack(apiaryId));
|
||||
const stored: StoredPack = {
|
||||
apiaryId,
|
||||
ownerUserId,
|
||||
savedAt: new Date().toISOString(),
|
||||
pack,
|
||||
};
|
||||
const db = await getDb();
|
||||
await db.put("offline-packs", stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** The apiary's pack, of whichever account loaded it; null when none or expired. */
|
||||
export async function getPack(apiaryId: string): Promise<StoredPack | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("offline-packs", apiaryId);
|
||||
return stored && !isExpired(stored, Date.now()) ? stored : null;
|
||||
}
|
||||
|
||||
/** The apiary's pack only if `ownerUserId` loaded it - another account's never counts. */
|
||||
export async function getOwnPack(apiaryId: string, ownerUserId: string): Promise<StoredPack | null> {
|
||||
const stored = await getPack(apiaryId);
|
||||
return stored?.ownerUserId === ownerUserId ? stored : null;
|
||||
}
|
||||
|
||||
/** Query key of getOwnPack (this device's storage, never the network). */
|
||||
export function ownPackQueryKey(ownerUserId: string | null, apiaryId: string) {
|
||||
return [...OFFLINE_PACKS_QUERY_KEY, ownerUserId, "apiary", apiaryId] as const;
|
||||
}
|
||||
|
||||
/** The packs `ownerUserId` loaded on this device, none expired. */
|
||||
export async function listPacks(ownerUserId: string): Promise<StoredPack[]> {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
return (await db.getAll("offline-packs")).filter(
|
||||
(stored) => stored.ownerUserId === ownerUserId && !isExpired(stored, now),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest of the given packs that lists the colony. A colony moved
|
||||
* between two loaded apiaries is where the later loaded pack says.
|
||||
*/
|
||||
export function packForColony(packs: readonly StoredPack[], colonyId: string): StoredPack | null {
|
||||
const holding = packs.filter((stored) =>
|
||||
stored.pack.colonies.some((colony) => colony.id === colonyId),
|
||||
);
|
||||
return [...holding].sort((a, b) => b.savedAt.localeCompare(a.savedAt))[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findPackForColony(
|
||||
colonyId: string,
|
||||
ownerUserId: string,
|
||||
): Promise<StoredPack | null> {
|
||||
return packForColony(await listPacks(ownerUserId), colonyId);
|
||||
}
|
||||
|
||||
export async function deleteAllPacks(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.clear("offline-packs");
|
||||
}
|
||||
|
||||
async function deletePack(apiaryId: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("offline-packs", apiaryId);
|
||||
}
|
||||
|
||||
/** Deletes packs older than 14 days; returns how many. */
|
||||
export async function purgeOldPacks(now: number): Promise<number> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("offline-packs", "readwrite");
|
||||
const expired = (await tx.store.getAll()).filter((stored) => isExpired(stored, now));
|
||||
await Promise.all(expired.map((stored) => tx.store.delete(stored.apiaryId)));
|
||||
await tx.done;
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
/** The server no longer lets this user see the apiary (or it is gone). */
|
||||
function isRevoked(error: unknown): boolean {
|
||||
return error instanceof ApiError && (error.status === 403 || error.status === 404);
|
||||
}
|
||||
|
||||
export interface PackRefreshOptions {
|
||||
/** The server rejected entries: reload the packs of their colonies now. */
|
||||
force: true;
|
||||
/** Colonies of the rejected entries. */
|
||||
colonyIds: readonly string[];
|
||||
}
|
||||
|
||||
function isDue(stored: StoredPack, now: number, options?: PackRefreshOptions): boolean {
|
||||
const age = ageOf(stored, now);
|
||||
if (age > PACK_AUTO_REFRESH_AFTER_MS) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
options?.force === true &&
|
||||
age > PACK_FORCED_REFRESH_AFTER_MS &&
|
||||
stored.pack.colonies.some((colony) => options.colonyIds.includes(colony.id))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a sync run: reloads the user's packs older than 1 h, so what the
|
||||
* server has just accepted or rejected (a colony ended meanwhile) shows up
|
||||
* offline too. With `force`, the packs listing a rejected entry's colony are
|
||||
* reloaded from 5 min of age on. Quietly - it never throws. A pack the user
|
||||
* may no longer read is deleted; at the first network error it stops (try
|
||||
* again next run).
|
||||
*/
|
||||
export async function refreshPacksAfterSync(
|
||||
ownerUserId: string,
|
||||
now: number,
|
||||
isCancelled: () => boolean = () => false,
|
||||
options?: PackRefreshOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await purgeOldPacks(now);
|
||||
if (getConnectivity() === "offline") {
|
||||
return;
|
||||
}
|
||||
const due = (await listPacks(ownerUserId)).filter((stored) => isDue(stored, now, options));
|
||||
for (const stored of due) {
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadPack(stored.apiaryId, ownerUserId);
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return;
|
||||
}
|
||||
if (isRevoked(error)) {
|
||||
await deletePack(stored.apiaryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Housekeeping only: the next sync run tries again.
|
||||
}
|
||||
}
|
||||
|
||||
/** Living colonies of all given packs, each once (newest pack wins), by code. */
|
||||
export function livingColoniesFromPacks(packs: readonly StoredPack[]): PackColony[] {
|
||||
const newestFirst = [...packs].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||
const byId = new Map<string, PackColony>();
|
||||
for (const stored of newestFirst) {
|
||||
for (const colony of stored.pack.colonies) {
|
||||
if (!byId.has(colony.id) && isLivingColony(colony)) {
|
||||
byId.set(colony.id, colony);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) =>
|
||||
a.code.localeCompare(b.code, "de", { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colony's inspections in its pack (the last three); null when no pack
|
||||
* lists the colony or the user may not read inspections.
|
||||
*/
|
||||
export function inspectionsFromPacks(
|
||||
packs: readonly StoredPack[],
|
||||
colonyId: string,
|
||||
): PackInspection[] | null {
|
||||
const inspections = packForColony(packs, colonyId)?.pack.inspections ?? null;
|
||||
return inspections?.filter((inspection) => inspection.colonyId === colonyId) ?? null;
|
||||
}
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/** "vor 5 Minuten" - how long ago the pack was loaded. */
|
||||
export function packAgeText(savedAt: string, now: number): string {
|
||||
const age = Math.max(0, now - Date.parse(savedAt));
|
||||
if (age < MINUTE_MS) {
|
||||
return "vor weniger als einer Minute";
|
||||
}
|
||||
if (age < HOUR_MS) {
|
||||
return `vor ${plural(Math.floor(age / MINUTE_MS), "Minute", "Minuten")}`;
|
||||
}
|
||||
if (age < DAY_MS) {
|
||||
return `vor ${plural(Math.floor(age / HOUR_MS), "Stunde", "Stunden")}`;
|
||||
}
|
||||
return `vor ${plural(Math.floor(age / DAY_MS), "Tag", "Tagen")}`;
|
||||
}
|
||||
|
||||
/** The local calendar day a pack was loaded, as "13.10.2026". */
|
||||
export function packDayText(savedAt: string): string {
|
||||
const date = new Date(savedAt);
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${day}.${month}.${date.getFullYear()}`;
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { colonyOverviewKey, fetchColonyOverview } from "./field-api";
|
||||
|
||||
/** The apiary's living colonies with their traffic-light inputs. */
|
||||
/**
|
||||
* The apiary's living colonies with their traffic-light inputs - offline
|
||||
* from the loaded pack, so the apiary page and the stand round
|
||||
* (`stillInApiary`) keep working at the apiary.
|
||||
*/
|
||||
export function useColonyOverview(apiaryId: string, enabled = true) {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: colonyOverviewKey(apiaryId),
|
||||
queryFn: () => fetchColonyOverview(apiaryId),
|
||||
queryFn: () => fetchColonyOverview(apiaryId, userId),
|
||||
enabled,
|
||||
// Without network the answer comes from this device's storage.
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user