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

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

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

Not rolled out. Remaining tasks follow in later commits.

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

No files matched your search

@@ -0,0 +1,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 };
}