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:
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
|
||||
|
||||
Reference in new issue
Block a user