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,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 };
|
||||
}
|
||||
Reference in new issue
Block a user