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,101 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { livingColoniesFromPacks } from "@/lib/apiaries/offline-pack";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
import { withPackFallback } from "@/lib/offline/pack-fallback";
/** What a bulk choice needs of a colony - a server colony or one from an offline pack. */
export type BulkColony = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
export interface BulkApiary {
id: string;
name: string;
}
/** Where the choices came from: the server, or - only without network - the loaded packs. */
export type ChoiceSource = "server" | "packs";
interface Choices<T> {
items: T[];
source: ChoiceSource;
}
/** Prefix of the queries here; they read the server or this device's storage, never both as one. */
const BULK_CHOICES_KEY = ["bulk-choices"] as const;
function byCode(a: BulkColony, b: BulkColony): number {
return a.code.localeCompare(b.code, "de", { numeric: true });
}
function livingOf(colonies: readonly BulkColony[]): BulkColony[] {
return colonies
.filter(isLivingColony)
.map(({ id, code, status, apiaryId }) => ({ id, code, status, apiaryId }))
.sort(byCode);
}
/**
* The apiaries to choose from: the server's active ones, or - only when the
* network failed - those of the user's offline packs. Names only: nothing
* else of an apiary (location!) is needed or kept.
*/
export function useApiaryChoices() {
const userId = useCurrentUserId();
return useQuery({
queryKey: [...BULK_CHOICES_KEY, userId, "apiaries"],
queryFn: (): Promise<Choices<BulkApiary>> =>
withPackFallback<Choices<BulkApiary>>(
async () => ({
source: "server",
items: (await fetchApiaries({ status: "ACTIVE" })).map(({ id, name }) => ({ id, name })),
}),
(packs) =>
packs.length === 0
? null
: {
source: "packs",
items: packs
.map(({ pack }) => ({ id: pack.apiary.id, name: pack.apiary.name }))
.sort((a, b) => a.name.localeCompare(b.name, "de")),
},
userId ?? "",
),
// Reads this device's storage when offline, never waits for the network.
networkMode: "always",
});
}
/**
* The living colonies of one apiary - ended ones never appear. Without
* network the apiary's offline pack answers; with no pack for it the
* request fails and the caller says so.
*/
export function useApiaryColonies(apiaryId: string | null) {
const userId = useCurrentUserId();
return useQuery({
queryKey: [...BULK_CHOICES_KEY, userId, "colonies", apiaryId],
queryFn: (): Promise<Choices<BulkColony>> =>
withPackFallback<Choices<BulkColony>>(
async () => ({
source: "server",
items: livingOf(await fetchColonies({ apiaryId: apiaryId ?? undefined })),
}),
(packs) =>
packs.some((stored) => stored.apiaryId === apiaryId)
? {
source: "packs",
items: livingOf(
livingColoniesFromPacks(packs).filter((colony) => colony.apiaryId === apiaryId),
),
}
: null,
userId ?? "",
),
enabled: apiaryId !== null && apiaryId !== "",
networkMode: "always",
});
}