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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
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();
});
});
+20 -2
View File
@@ -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",
});
}