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

@@ -5,25 +5,89 @@ import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import {
OFFLINE_PACKS_QUERY_KEY,
listPacks,
livingColoniesFromPacks,
} from "@/lib/apiaries/offline-pack";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
const PLACEHOLDER = "Volk wählen";
const LOADING_PLACEHOLDER = "Völker werden geladen…";
const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
const OFFLINE_PACK_HINT = "Offline: nur Völker der geladenen Stände.";
const OFFLINE_NOT_LOADED_HINT = "Kein Netz. Lade den Stand vorher mit „Für offline laden“.";
/** What a colony choice needs - a server colony or one from an offline pack. */
type ColonyChoice = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
function useColonies() {
return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
}
type ChoicesHint = "load-error" | "offline-not-loaded" | "from-packs";
const HINT_TEXTS: Record<ChoicesHint, string> = {
"load-error": LOAD_ERROR_HINT,
"offline-not-loaded": OFFLINE_NOT_LOADED_HINT,
"from-packs": OFFLINE_PACK_HINT,
};
interface LivingColonyChoices {
colonies: readonly ColonyChoice[] | undefined;
isLoading: boolean;
hint: ChoicesHint | null;
}
/**
* Every colony from the shared ["colonies"] query - one request for every
* colony list on the page. Only when that failed for lack of network (never
* on a server refusal) the living colonies of the user's offline packs step
* in, from their own query: pack colonies are partial records and never go
* into the shared cache.
*/
function useLivingColonyChoices(): LivingColonyChoices {
const userId = useCurrentUserId();
const server = useColonies();
// failureReason: offline the retry pauses, so `error` stays unset.
const isOffline =
server.data === undefined && isNetworkError(server.error ?? server.failureReason);
const packs = useQuery({
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "living-colonies"],
queryFn: async () => livingColoniesFromPacks(await listPacks(userId ?? "")),
enabled: isOffline && userId !== null,
// Reads this device's storage, never the network.
networkMode: "always",
});
if (!isOffline) {
return {
colonies: server.data,
isLoading: server.isLoading,
hint: server.isError && server.data === undefined ? "load-error" : null,
};
}
const packColonies = packs.data && packs.data.length > 0 ? packs.data : undefined;
let hint: ChoicesHint | null = "offline-not-loaded";
if (packColonies) {
hint = "from-packs";
} else if (packs.isLoading) {
hint = null;
}
return { colonies: packColonies, isLoading: packs.isLoading, hint };
}
interface ColonyChoicesProps {
id: string;
value: string;
onChange: (colonyId: string) => void;
/** The colonies on offer; `undefined` while loading. */
options: readonly Colony[] | undefined;
options: readonly ColonyChoice[] | undefined;
/** Every known colony, so a chosen one is named even if it is not offered. */
colonies: readonly Colony[] | undefined;
colonies: readonly ColonyChoice[] | undefined;
isLoading: boolean;
dataField?: string;
describedBy?: string;
@@ -65,6 +129,9 @@ interface LivingColonySelectFieldProps {
* Weak, conspicuous and queenless colonies are included - they need the most
* attention - and none that has ended. A colony handed in by a link that has
* ended since is still named, just not offered.
*
* Without network it offers the living colonies of the apiaries loaded for
* offline use ("Für offline laden") and says so.
*/
export function LivingColonySelectField({
triggerId,
@@ -76,18 +143,25 @@ export function LivingColonySelectField({
emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.",
dataField = "colonyId",
}: LivingColonySelectFieldProps) {
const { data: colonies, isLoading, isError } = useColonies();
const { colonies, isLoading, hint: choicesHint } = useLivingColonyChoices();
const living = colonies?.filter(
(colony) => isLivingColony(colony) && colony.id !== excludeId,
);
function hint(): string | undefined {
if (choicesHint) {
return HINT_TEXTS[choicesHint];
}
return living?.length === 0 ? emptyHint : undefined;
}
return (
<Field
id={triggerId}
label={label}
required
error={error}
hint={isError ? LOAD_ERROR_HINT : living?.length === 0 ? emptyHint : undefined}
hint={hint()}
>
{({ id, describedBy, invalid }) => (
<ColonyChoices