"use client"; import { useQuery } from "@tanstack/react-query"; 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; function useColonies() { return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); } type ChoicesHint = "load-error" | "offline-not-loaded" | "from-packs"; const HINT_TEXTS: Record = { "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 ColonyChoice[] | undefined; /** Every known colony, so a chosen one is named even if it is not offered. */ colonies: readonly ColonyChoice[] | undefined; isLoading: boolean; dataField?: string; describedBy?: string; invalid?: boolean; } /** The colony select: a RecordSelect named by code and status ("V-03 (Weisellos)"). */ function ColonyChoices({ colonies, options, isLoading, ...props }: ColonyChoicesProps) { return ( ); } interface LivingColonySelectFieldProps { /** Id of the select trigger, used to focus it on a validation error. */ triggerId: string; value: string; onChange: (colonyId: string) => void; error?: string | null; label?: string; /** A colony that must not be offered, e.g. the one another is merged into. */ excludeId?: string; /** Shown when there is nothing to choose from. */ emptyHint?: string; /** `data-field` of the trigger; the capture forms focus "colonyId" on errors. */ dataField?: string; } /** * Picks a colony that still lives in the apiary: the one a new record * (inspection, varroa count, feeding, treatment, swarm control check) is filed * under, the colony a queen moves into, the colony merged into another. * 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, value, onChange, error, label = "Volk", excludeId, emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.", dataField = "colonyId", }: LivingColonySelectFieldProps) { 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 ( {({ id, describedBy, invalid }) => ( )} ); } interface ColonySelectFieldProps { id: string; value: string; onChange: (colonyId: string) => void; label?: string; error?: string | null; dataField?: string; } /** * Picks the colony a record belongs to. Every colony-scoped record * (inspection, treatment, feeding, varroa measurement, swarm control check) * can be re-filed under a different colony after the fact, because filing one * against the wrong colony is a data-entry error rather than a fact of * beekeeping - and the only alternative repair would be deleting the record. * * Ended colonies stay in the list: a record already filed against one has * to remain visible and selectable, otherwise opening the form would silently * re-file it somewhere else. */ export function ColonySelectField({ id: triggerId, value, onChange, label = "Volk", error, dataField, }: ColonySelectFieldProps) { const { data: colonies, isLoading, isError } = useColonies(); return ( {({ id, describedBy, invalid }) => ( )} ); }