From 2a6f0b684cb25e6abd8ac0c0401260cd87e9c716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Mon, 19 Oct 2026 16:05:27 +0200 Subject: [PATCH] feat(t5): Lebenslinie als Modell und Daten-Hook (T5b B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildLifeline (src/lib/colonies/lifeline.ts): eine Zeitachse je Volk mit Stärke in besetzten Waben (seit 2026-10-17, ältere Werte nur in der Tabelle als „Skala (alt)“), Milbenbalken, Königinnen-Spannen, Ereignissen je Tag, Behandlungen, Lücken über 45 Tage und den nächsten 21 Tagen; überfällige Schritte stehen bei heute mit „fällig seit“. - useColonyLifeline: nur vorhandene Query-Keys, höchstens 5 Königinnen, 403 blendet eine Spur aus, offline ohne Daten kein Dauer-Laden. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/colonies/lifeline.spec.ts | 473 ++++++++++++++++++ ApiCore-Frontend/src/lib/colonies/lifeline.ts | 400 +++++++++++++++ .../lib/colonies/use-colony-lifeline.spec.tsx | 185 +++++++ .../src/lib/colonies/use-colony-lifeline.ts | 138 +++++ ApiCore-Frontend/src/lib/varroa/bands.ts | 2 +- 5 files changed, 1197 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Frontend/src/lib/colonies/lifeline.spec.ts create mode 100644 ApiCore-Frontend/src/lib/colonies/lifeline.ts create mode 100644 ApiCore-Frontend/src/lib/colonies/use-colony-lifeline.spec.tsx create mode 100644 ApiCore-Frontend/src/lib/colonies/use-colony-lifeline.ts diff --git a/ApiCore-Frontend/src/lib/colonies/lifeline.spec.ts b/ApiCore-Frontend/src/lib/colonies/lifeline.spec.ts new file mode 100644 index 0000000..799e0e4 --- /dev/null +++ b/ApiCore-Frontend/src/lib/colonies/lifeline.spec.ts @@ -0,0 +1,473 @@ +import { describe, expect, it } from "vitest"; +import type { ColonyHistory } from "./types"; +import type { Inspection } from "@/lib/inspections/types"; +import type { VarroaMeasurement } from "@/lib/varroa/types"; +import type { VarroaBand } from "@/lib/varroa/bands"; +import type { Treatment } from "@/lib/treatments/types"; +import type { Queen } from "@/lib/queens/types"; +import type { Task } from "@/lib/tasks/types"; +import type { ColonyNextSteps, NextStep } from "@/lib/autopilot/types"; +import { buildLifeline, type LifelineInput } from "./lifeline"; + +const TODAY = "2026-10-19"; +const SINCE = "2026-05-01"; + +function at(day: string): string { + return `${day}T00:00:00.000Z`; +} + +function inspection(day: string, colonyStrength: number | null, broodFrames: number | null = null): Inspection { + return { id: `i-${day}`, date: at(day), colonyStrength, broodFrames } as Inspection; +} + +function mites( + day: string, + rate: number, + extra: Partial = {}, +): VarroaMeasurement { + return { + id: `v-${day}`, + date: at(day), + method: "NATURAL_MITE_FALL", + durationMinutes: 3 * 24 * 60, + calculatedValue: rate, + rating: null, + ...extra, + } as VarroaMeasurement; +} + +const BAND: VarroaBand = { + method: "NATURAL_MITE_FALL", + unit: "MITES_PER_DAY", + yellowFrom: 5, + redAbove: 10, + reason: "SUMMER", + youngColony: false, +}; + +function treatment(start: string, end: string | null, product = "Oxuvar"): Treatment { + return { id: `t-${start}`, date: at(start), startDate: at(start), endDate: end ? at(end) : null, product } as Treatment; +} + +function queen(id: string, code: string, markingColor: string | null, hatchYear: number | null): Queen { + return { id, code, markingColor, hatchYear } as Queen; +} + +const EMPTY_HISTORY: ColonyHistory = { apiaryHistory: [], queenHistory: [], lineageAsSource: [], lineageAsResult: [] }; + +function input(overrides: Partial = {}): LifelineInput { + return { + colony: { id: "c-1", code: "B3", createdAt: at("2025-03-12") }, + history: EMPTY_HISTORY, + inspections: [], + varroa: [], + treatments: [], + feedings: [], + swarmChecks: [], + queens: [], + ...overrides, + }; +} + +function step(title: string, dueOn: string | null): NextStep { + return { key: `task:${title}`, kind: "TASK", intent: null, title, reasons: [], dueOn } as unknown as NextStep; +} + +function task(title: string, dueDate: string, extra: Partial = {}): Task { + return { + id: title, + title, + dueDate: at(dueDate), + status: "OPEN", + relatedType: "COLONY", + relatedId: "c-1", + ...extra, + } as Task; +} + +describe("buildLifeline", () => { + it("values before STRENGTH_IN_FRAMES_SINCE are not in the curve but in the table", () => { + const model = buildLifeline( + input({ inspections: [inspection("2026-04-10", 6), inspection("2026-06-01", 9, 5)] }), + TODAY, + { strengthInFramesSince: SINCE }, + ); + + expect(model.strength?.points).toEqual([{ date: "2026-06-01", frames: 9 }]); + expect(model.table).toContainEqual({ date: "2026-04-10", event: "Durchsicht", value: "Stärke 6 · Skala (alt)" }); + expect(model.table).toContainEqual({ + date: "2026-06-01", + event: "Durchsicht", + value: "9 besetzte Waben · 5 Brutwaben", + }); + }); + + it("the strength axis runs to at least 20, rounded up to 5 and capped at 40", () => { + const low = buildLifeline(input({ inspections: [inspection("2026-06-01", 9)] }), TODAY, { strengthInFramesSince: SINCE }); + const high = buildLifeline(input({ inspections: [inspection("2026-06-01", 23)] }), TODAY, { strengthInFramesSince: SINCE }); + const capped = buildLifeline(input({ inspections: [inspection("2026-06-01", 40)] }), TODAY, { strengthInFramesSince: SINCE }); + + expect([low.strength?.max, high.strength?.max, capped.strength?.max]).toEqual([20, 25, 40]); + }); + + it("a gap over 45 days breaks the line", () => { + const model = buildLifeline( + input({ + inspections: [ + inspection("2026-06-01", 8), + inspection("2026-07-16", 10), // 45 days: still one line + inspection("2026-09-01", 12), // 47 days: breaks + ], + }), + TODAY, + { strengthInFramesSince: SINCE }, + ); + + expect(model.strength?.breaks).toEqual(["2026-09-01"]); + }); + + it("varroa bars span the real exposure time", () => { + const model = buildLifeline( + input({ + varroa: [ + mites("2026-08-10", 2.1), + mites("2026-09-01", 3, { durationMinutes: 12 * 24 * 60 }), // capped at 7 days + mites("2026-09-20", 3, { durationMinutes: 60 }), // at least 1 day + mites("2026-09-25", 50, { method: "WASHING_METHOD" }), // not a rate per day + ], + }), + TODAY, + ); + + expect(model.mites?.bars.map(({ from, to }) => [from, to])).toEqual([ + ["2026-08-07", "2026-08-10"], + ["2026-08-25", "2026-09-01"], + ["2026-09-19", "2026-09-20"], + ]); + }); + + it("rating.light (T4) wins over varroaLight (T3)", () => { + const rated = mites("2026-09-01", 2, { + rating: { light: "red", reasonCode: null, band: { ...BAND, redAbove: 1.5 }, postTreatmentUntil: null }, + }); + const model = buildLifeline(input({ varroa: [rated, mites("2026-09-10", 7)], varroaBand: BAND }), TODAY); + + expect(model.mites?.bars.map((bar) => [bar.light, bar.redAbove])).toEqual([ + ["red", 1.5], + ["yellow", undefined], + ]); + expect(model.mites?.threshold).toBe(10); + }); + + it("a measurement under treatment effect has no light but says so", () => { + const model = buildLifeline( + input({ + varroa: [ + mites("2026-09-01", 12, { + rating: { light: "POST_TREATMENT", reasonCode: null, band: null, postTreatmentUntil: "2026-09-20" }, + }), + ], + }), + TODAY, + ); + + expect(model.mites?.bars[0]).toMatchObject({ light: null, label: "Behandlungseffekt" }); + }); + + it("queen spans come from queenHistory and the colour from markingColor", () => { + const model = buildLifeline( + input({ + history: { + ...EMPTY_HISTORY, + queenHistory: [ + { id: "h2", queenId: "q2", startDate: at("2026-04-20"), endDate: null }, + { id: "h1", queenId: "q1", startDate: at("2025-03-12"), endDate: at("2026-04-20") }, + ], + }, + queens: [queen("q1", "Q-7", "Gelb", 2024), queen("q2", "Q-12", "blau", 2025)], + }), + TODAY, + ); + + expect(model.queen?.spans).toEqual([ + { from: "2025-03-12", to: "2026-04-20", code: "Q-7", color: "yellow", colorWord: "gelb", hatchYear: 2024 }, + { from: "2026-04-20", to: null, code: "Q-12", color: "blue", colorWord: "blau", hatchYear: 2025 }, + ]); + expect(model.events).toContainEqual({ date: "2026-04-20", kind: "queens", count: 1 }); + }); + + it("an unknown marking colour is dashed and named", () => { + const model = buildLifeline( + input({ + history: { ...EMPTY_HISTORY, queenHistory: [{ id: "h", queenId: "q", startDate: at("2026-05-01"), endDate: null }] }, + queens: [queen("q", "Q-1", "Lila", null)], + }), + TODAY, + ); + const noColour = buildLifeline( + input({ + history: { ...EMPTY_HISTORY, queenHistory: [{ id: "h", queenId: "q", startDate: at("2026-05-01"), endDate: null }] }, + queens: [queen("q", "Q-1", null, null)], + }), + TODAY, + ); + + expect(model.queen?.spans[0]).toMatchObject({ color: null, colorWord: "lila" }); + expect(noColour.queen?.spans[0]).toMatchObject({ color: null, colorWord: "ohne Farbe" }); + }); + + it("treatments become spans", () => { + const model = buildLifeline( + input({ treatments: [treatment("2026-09-01", "2026-09-14"), treatment("2026-07-01", null, "Ameisensäure")] }), + TODAY, + ); + + expect(model.treatments).toEqual([ + { from: "2026-07-01", to: "2026-07-01", product: "Ameisensäure" }, + { from: "2026-09-01", to: "2026-09-14", product: "Oxuvar" }, + ]); + expect(model.table).toContainEqual({ date: "2026-09-01", event: "Behandlung Oxuvar", value: "01.09.–14.09." }); + expect(model.events).toContainEqual({ date: "2026-09-01", kind: "treatments", count: 1 }); + }); + + it("events on the same day become one icon with a count", () => { + const model = buildLifeline( + input({ + feedings: [{ id: "f", date: at("2026-09-05"), amount: 5, unit: "kg", feedType: "FEED_SYRUP" } as never], + swarmChecks: [{ id: "s", date: at("2026-09-05"), queenCellsFound: 2 } as never], + history: { + ...EMPTY_HISTORY, + apiaryHistory: [{ id: "m", apiaryId: "a", movedAt: at("2026-08-01"), notes: null }], + lineageAsSource: [{ id: "l", type: "SPLIT", toColonyId: "c-2", date: at("2026-06-01") }], + }, + }), + TODAY, + ); + + expect(model.events).toEqual([ + { date: "2026-06-01", kind: "colonies", count: 1 }, + { date: "2026-08-01", kind: "apiaries", count: 1 }, + { date: "2026-09-05", kind: "swarm-control", count: 2 }, + ]); + }); + + it("the future shows T4 next steps, otherwise open tasks, at most 3", () => { + const nextSteps = { + steps: [step("Durchsicht", "2026-10-25"), step("Ohne Termin", null), step("Füttern", "2026-10-21")], + withdrawalUntil: "2026-11-02", + } as ColonyNextSteps; + const fromSteps = buildLifeline(input({ nextSteps, tasks: [task("Aufgabe", "2026-10-20")] }), TODAY); + const fromTasks = buildLifeline( + input({ + tasks: [ + task("A", "2026-10-30"), + task("B", "2026-10-20"), + task("C", "2026-10-22"), + task("D", "2026-10-24"), + task("später", "2026-11-20"), + task("erledigt", "2026-10-21", { status: "DONE" }), + task("anderes Volk", "2026-10-21", { relatedId: "c-9" }), + ], + }), + TODAY, + ); + + expect(fromSteps.future).toEqual([ + { date: "2026-10-21", dueOn: "2026-10-21", overdue: false, title: "Füttern" }, + { date: "2026-10-25", dueOn: "2026-10-25", overdue: false, title: "Durchsicht" }, + ]); + expect(fromSteps.withdrawalUntil).toBe("2026-11-02"); + expect(fromTasks.future.map((entry) => entry.title)).toEqual(["B", "C", "D"]); + expect(fromTasks.withdrawalUntil).toBeNull(); + }); + + it("an overdue next step is drawn at today and keeps its real due day", () => { + const nextSteps = { steps: [step("Durchsicht", "2026-10-12")], withdrawalUntil: null } as unknown as ColonyNextSteps; + + const model = buildLifeline(input({ nextSteps }), TODAY); + + expect(model.future).toEqual([{ date: TODAY, dueOn: "2026-10-12", overdue: true, title: "Durchsicht" }]); + expect(model.table).toContainEqual({ date: TODAY, event: "Durchsicht", value: "fällig seit 12.10." }); + }); + + it("has data when inspections lie outside the window and says whether the window shows any", () => { + const old = input({ + colony: { id: "c-1", code: "B3", createdAt: at("2023-05-02") }, + inspections: [inspection("2024-06-01", 5)], + }); + + expect(buildLifeline(old, TODAY)).toMatchObject({ hasData: true, hasDataInWindow: false }); + expect(buildLifeline(old, TODAY, { showAll: true })).toMatchObject({ hasData: true, hasDataInWindow: true }); + }); + + it("a treatment started before the window still gets a row at the window start, but no icon", () => { + const old = input({ + colony: { id: "c-1", code: "B3", createdAt: at("2023-05-02") }, + treatments: [treatment("2025-10-01", "2025-11-30")], + }); + + const model = buildLifeline(old, TODAY); + + expect(model.from).toBe("2025-10-19"); + expect(model.treatments).toHaveLength(1); + expect(model.table).toContainEqual({ date: "2025-10-19", event: "Behandlung Oxuvar", value: "01.10.–30.11." }); + expect(model.events).toEqual([]); + }); + + it("the queen event names the queen of that history entry even when two start on one day", () => { + const model = buildLifeline( + input({ + history: { + ...EMPTY_HISTORY, + queenHistory: [ + { id: "h1", queenId: "q1", startDate: at("2026-05-01"), endDate: at("2026-05-01") }, + { id: "h2", queenId: "q2", startDate: at("2026-05-01"), endDate: null }, + ], + }, + queens: [queen("q1", "Q-1", "Gelb", 2024), queen("q2", "Q-2", "Blau", 2025)], + }), + TODAY, + ); + + expect(model.table.filter((row) => row.event.startsWith("Königin")).map((row) => row.event).sort()).toEqual([ + "Königin Q-1", + "Königin Q-2", + ]); + }); + + it("a back-dated inspection entered after the switch counts frames", () => { + const backDated = { ...inspection("2026-04-10", 9), createdAt: at("2026-06-02") }; + + const model = buildLifeline(input({ inspections: [backDated] }), TODAY, { strengthInFramesSince: SINCE }); + + expect(model.strength?.points).toEqual([{ date: "2026-04-10", frames: 9 }]); + }); + + it("the gap is measured between all inspections, not only those with a strength", () => { + const model = buildLifeline( + input({ inspections: [inspection("2026-06-01", 8), inspection("2026-07-01", null), inspection("2026-08-01", 10)] }), + TODAY, + { strengthInFramesSince: SINCE }, + ); + + expect(model.strength?.breaks).toEqual([]); + }); + + it("a bar under treatment effect keeps the red limit of its band", () => { + const model = buildLifeline( + input({ + varroa: [ + mites("2026-09-01", 12, { + rating: { light: "POST_TREATMENT", reasonCode: null, band: BAND, postTreatmentUntil: "2026-09-20" }, + }), + ], + }), + TODAY, + ); + + expect(model.mites?.bars[0]).toMatchObject({ light: null, label: "Behandlungseffekt", redAbove: 10 }); + }); + + it("a 403 on a track drops the track without an error", () => { + const model = buildLifeline( + input({ inspections: undefined, varroa: undefined, treatments: undefined, queens: undefined }), + TODAY, + ); + + expect(model.missing).toEqual(["strength", "mites", "treatments", "queen"]); + expect(model.strength).toBeUndefined(); + expect(model.mites).toBeUndefined(); + expect(model.queen).toBeUndefined(); + expect(model.treatments).toEqual([]); + }); + + it("the default window is the last 12 months plus 21 days, showAll widens it", () => { + const old = input({ colony: { id: "c-1", code: "B3", createdAt: at("2023-05-02") } }); + const young = input({ colony: { id: "c-1", code: "B3", createdAt: at("2026-06-15") } }); + + expect(buildLifeline(old, TODAY)).toMatchObject({ startDate: "2023-05-02", from: "2025-10-19", to: "2026-11-09" }); + expect(buildLifeline(old, TODAY, { showAll: true })).toMatchObject({ from: "2023-05-02" }); + expect(buildLifeline(young, TODAY)).toMatchObject({ from: "2026-06-15" }); + }); + + it("life begins with the split it came from when that is earlier than the record", () => { + const model = buildLifeline( + input({ + colony: { id: "c-1", code: "B3", createdAt: at("2026-06-20") }, + history: { ...EMPTY_HISTORY, lineageAsResult: [{ id: "l", type: "SPLIT", fromColonyId: "c-0", date: at("2026-06-10") }] }, + }), + TODAY, + ); + + expect(model.startDate).toBe("2026-06-10"); + }); + + it("desc has at most four sentences and names the last values", () => { + const model = buildLifeline( + input({ + inspections: [inspection("2026-07-10", 14), inspection("2026-10-02", 9)], + varroa: [mites("2026-09-28", 2.1, { rating: { light: "green", reasonCode: null, band: BAND, postTreatmentUntil: null } })], + treatments: [treatment("2026-08-01", "2026-08-14"), treatment("2026-09-01", "2026-09-14")], + history: { ...EMPTY_HISTORY, queenHistory: [{ id: "h", queenId: "q", startDate: at("2026-04-20"), endDate: null }] }, + queens: [queen("q", "Q-12", "Blau", 2025)], + }), + TODAY, + { strengthInFramesSince: SINCE }, + ); + + expect(model.desc).toBe( + "Volk B3 seit 12.03.2025. " + + "Stärke zuletzt 9 Waben am 02.10., höchstens 14 Waben im Juli. " + + "Milben zuletzt 2,1 pro Tag am 28.09. – Gut. " + + "Königin Q-12, Schlupf 2025, blau, seit 20.04.2026; 2 Behandlungen in diesem Ausschnitt.", + ); + expect(model.desc.split(/\.\s+(?=[A-ZÄÖÜ])/)).toHaveLength(4); + }); + + it("the table comes from the same model and covers the same span", () => { + const model = buildLifeline( + input({ + colony: { id: "c-1", code: "B3", createdAt: at("2024-01-10") }, + inspections: [inspection("2024-06-01", 5), inspection("2026-06-01", 9)], + varroa: [mites("2026-09-28", 8.4, { rating: { light: "yellow", reasonCode: null, band: BAND, postTreatmentUntil: null } })], + nextSteps: { steps: [step("Durchsicht", "2026-10-25")], withdrawalUntil: null } as unknown as ColonyNextSteps, + }), + TODAY, + { strengthInFramesSince: SINCE }, + ); + + expect(model.table.every((row) => row.date >= model.from && row.date <= model.to)).toBe(true); + expect(model.table.map((row) => row.date)).toEqual(["2026-10-25", "2026-09-28", "2026-06-01"]); + expect(model.table[0]).toEqual({ date: "2026-10-25", event: "Durchsicht", value: "geplant" }); + expect(model.table[1]).toEqual({ date: "2026-09-28", event: "Varroa", value: "8,4 Milben/Tag · Achtung" }); + }); + + it("without inspections and measurements there is no data, but queen and events still show", () => { + const model = buildLifeline( + input({ + history: { ...EMPTY_HISTORY, queenHistory: [{ id: "h", queenId: "q", startDate: at("2026-04-20"), endDate: null }] }, + queens: [queen("q", "Q-12", "Blau", 2025)], + }), + TODAY, + ); + + expect(model.hasData).toBe(false); + expect(model.queen?.spans).toHaveLength(1); + expect(model.colonyCode).toBe("B3"); + }); + + it("builds three years with 150 inspections in under 50 ms", () => { + const inspections = Array.from({ length: 150 }, (_, index) => { + const day = new Date(Date.UTC(2023, 10, 1) + index * 7 * 86_400_000).toISOString().slice(0, 10); + return inspection(day, 5 + (index % 12), 3); + }); + const started = performance.now(); + + buildLifeline(input({ colony: { id: "c-1", code: "B3", createdAt: at("2023-11-01") }, inspections }), TODAY, { + showAll: true, + strengthInFramesSince: "2023-01-01", + }); + + expect(performance.now() - started).toBeLessThan(50); + }); +}); diff --git a/ApiCore-Frontend/src/lib/colonies/lifeline.ts b/ApiCore-Frontend/src/lib/colonies/lifeline.ts new file mode 100644 index 0000000..3c4a16f --- /dev/null +++ b/ApiCore-Frontend/src/lib/colonies/lifeline.ts @@ -0,0 +1,400 @@ +import type { AreaKey } from "@/lib/areas"; +import type { ColonyNextSteps } from "@/lib/autopilot/types"; +import { daysBetween, formatDayMonth } from "@/lib/autopilot/format"; +import { berlinCalendarDay } from "@/lib/dates"; +import type { Feeding } from "@/lib/feedings/types"; +import type { Inspection } from "@/lib/inspections/types"; +import type { Queen } from "@/lib/queens/types"; +import type { SwarmControlCheck } from "@/lib/swarm-control/types"; +import type { Task } from "@/lib/tasks/types"; +import type { Treatment } from "@/lib/treatments/types"; +import { LIGHT_WORDS, varroaLight, type VarroaBand } from "@/lib/varroa/bands"; +import { formatMitesPerDay } from "@/lib/varroa/format"; +import type { VarroaMeasurement } from "@/lib/varroa/types"; +import type { StatusLight } from "./status-light"; +import type { Colony, ColonyHistory } from "./types"; + +/** + * Since this day `Inspection.colonyStrength` counts occupied frames (T3 Task 9); + * before it was a 1-10 scale. Older values stay out of the curve (spec 8.1). + */ +// The frames stepper went live with frontend sha-cda3e02 on 2026-10-16 at 22:28 +// (runbook #28); the first full day is the 17th. Production has no inspection +// created between 2026-10-10 and 2026-10-19, so no value sits on the boundary. +export const STRENGTH_IN_FRAMES_SINCE = "2026-10-17"; + +const GAP_DAYS = 45; +const FUTURE_DAYS = 21; +const MAX_FUTURE = 3; +const MIN_AXIS = 20; +const AXIS_STEP = 5; +const MAX_FRAMES = 40; +const MAX_EXPOSURE_DAYS = 7; +const MINUTES_PER_DAY = 24 * 60; +const DAY_MS = 24 * 60 * 60 * 1000; + +export type QueenColor = "white" | "yellow" | "red" | "green" | "blue"; +export type LifelineTrack = "strength" | "mites" | "treatments" | "queen"; + +const QUEEN_COLORS: Readonly> = { + weiß: "white", weiss: "white", white: "white", + gelb: "yellow", yellow: "yellow", + rot: "red", red: "red", + grün: "green", gruen: "green", green: "green", + blau: "blue", blue: "blue", +}; +const COLOR_WORDS: Readonly> = { + white: "weiß", yellow: "gelb", red: "rot", green: "grün", blue: "blau", +}; +/** One icon per day: the most telling kind of that day wins. */ +const EVENT_ORDER: readonly AreaKey[] = ["queens", "colonies", "apiaries", "treatments", "swarm-control", "feedings"]; +const MONTH = new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", month: "long" }); + +/** A track left `undefined` could not be read (403) and drops out without an error. */ +export interface LifelineInput { + colony: Pick; + history?: ColonyHistory; + inspections?: readonly Inspection[]; + varroa?: readonly VarroaMeasurement[]; + /** T3 fallback for measurements without a stored rating. */ + varroaBand?: VarroaBand | null; + treatments?: readonly Treatment[]; + feedings?: readonly Feeding[]; + swarmChecks?: readonly SwarmControlCheck[]; + queens?: readonly Queen[]; + /** T4; without it the future comes from `tasks`. */ + nextSteps?: ColonyNextSteps; + tasks?: readonly Task[]; +} + +/** Default: the last 12 months plus 21 days ahead. */ +export interface LifelineOptions { + strengthInFramesSince?: string; + showAll?: boolean; +} + +export interface MiteBar { + from: string; + to: string; + perDay: number; + /** null: not rated (treatment effect or no band). */ + light: StatusLight | null; + redAbove?: number; + label?: string; +} + +export interface QueenSpan { + from: string; + to: string | null; + code: string; + color: QueenColor | null; + colorWord: string; + hatchYear: number | null; +} + +export interface LifelineRow { + date: string; + event: string; + value: string; +} + +/** All dates are calendar days (YYYY-MM-DD). */ +export interface LifelineModel { + colonyCode: string; + startDate: string; + from: string; + to: string; + strength?: { points: { date: string; frames: number }[]; breaks: string[]; max: number }; + mites?: { bars: MiteBar[]; threshold?: number }; + treatments: { from: string; to: string; product: string }[]; + queen?: { spans: QueenSpan[] }; + events: { date: string; kind: AreaKey; count: number }[]; + /** `date` is where it is drawn (never before today); `dueOn` is the real due day. */ + future: { date: string; dueOn: string; overdue: boolean; title: string }[]; + withdrawalUntil: string | null; + missing: LifelineTrack[]; + /** Any inspection or measurement at all, window or not. */ + hasData: boolean; + /** Any inspection or measurement inside [from, to]. */ + hasDataInWindow: boolean; + desc: string; + /** Newest first, same span as the graphic. */ + table: LifelineRow[]; +} + +interface Happening extends LifelineRow { + kind: AreaKey; +} + +function dayOf(timestamp: string): string { + return berlinCalendarDay(timestamp); +} + +function addDays(day: string, days: number): string { + return new Date(Date.parse(`${day}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10); +} + +function yearBefore(day: string): string { + const date = new Date(`${day}T00:00:00Z`); + date.setUTCFullYear(date.getUTCFullYear() - 1); + return date.toISOString().slice(0, 10); +} + +/** „12.03.2025“ */ +function formatDay(day: string): string { + return `${formatDayMonth(day)}${day.slice(0, 4)}`; +} + +function byDate(left: T, right: T): number { + return left.date.localeCompare(right.date); +} + +export function queenColorOf(markingColor: string | null): { color: QueenColor | null; colorWord: string } { + const word = markingColor?.trim().toLowerCase() ?? ""; + const color = QUEEN_COLORS[word] ?? null; + return { color, colorWord: color ? COLOR_WORDS[color] : word || "ohne Farbe" }; +} + +function lifeStart(input: LifelineInput): string { + const days = [ + dayOf(input.colony.createdAt), + ...(input.history?.lineageAsResult ?? []).map((entry) => dayOf(entry.date)), + ...(input.inspections ?? []).map((entry) => dayOf(entry.date)), + ...(input.varroa ?? []).map((entry) => dayOf(entry.date)), + ]; + return days.reduce((earliest, day) => (day < earliest ? day : earliest)); +} + +function inspectionValue(inspection: Inspection, inFrames: boolean): string { + const parts: string[] = []; + if (inspection.colonyStrength !== null) { + parts.push(inFrames ? `${inspection.colonyStrength} besetzte Waben` : `Stärke ${inspection.colonyStrength} · Skala (alt)`); + } + if (inspection.broodFrames !== null) parts.push(`${inspection.broodFrames} Brutwaben`); + return parts.join(" · ") || "—"; +} + +function strengthTrack(inspections: readonly Inspection[], since: string, inWindow: (day: string) => boolean) { + const dated = inspections + .map((inspection) => ({ date: dayOf(inspection.date), inspection })) + .filter(({ date }) => inWindow(date)) + .sort(byDate); + // A back-dated entry made with the new form already counts frames. + const inFrames = (inspection: Inspection, date: string) => + (inspection.createdAt ? dayOf(inspection.createdAt) : date) >= since; + const rows = dated.map(({ date, inspection }) => ({ + date, + event: "Durchsicht", + value: inspectionValue(inspection, inFrames(inspection, date)), + })); + const isPoint = ({ date, inspection }: { date: string; inspection: Inspection }) => + inspection.colonyStrength !== null && inFrames(inspection, date); + // The gap counts any inspection, also one without a strength. + const breaks = dated + .filter((entry, index) => index > 0 && isPoint(entry) && daysBetween(dated[index - 1].date, entry.date) > GAP_DAYS) + .map(({ date }) => date); + const points = dated + .filter(isPoint) + .map(({ date, inspection }) => ({ date, frames: inspection.colonyStrength as number })); + const top = Math.max(0, ...points.map((point) => point.frames)); + const max = Math.min(MAX_FRAMES, Math.max(MIN_AXIS, Math.ceil(top / AXIS_STEP) * AXIS_STEP)); + return { strength: { points, breaks, max }, rows }; +} + +function miteBar(measurement: VarroaMeasurement, to: string, band: VarroaBand | null | undefined): MiteBar { + const perDay = measurement.calculatedValue as number; + const exposure = Math.round((measurement.durationMinutes ?? 0) / MINUTES_PER_DAY); + const from = addDays(to, -Math.min(MAX_EXPOSURE_DAYS, Math.max(1, exposure))); + const rating = measurement.rating; + if (rating?.light === "POST_TREATMENT") return { from, to, perDay, light: null, redAbove: rating.band?.redAbove, label: "Behandlungseffekt" }; + const light = rating?.light ?? varroaLight(perDay, band); + return { from, to, perDay, light, redAbove: rating?.band?.redAbove }; +} + +function mitesTrack( + varroa: readonly VarroaMeasurement[], + band: VarroaBand | null | undefined, + inWindow: (day: string) => boolean, +) { + const bars = varroa + .filter((entry) => entry.method === "NATURAL_MITE_FALL" && entry.calculatedValue !== null) + .map((entry) => ({ date: dayOf(entry.date), entry })) + .filter(({ date }) => inWindow(date)) + .sort(byDate) + .map(({ date, entry }) => miteBar(entry, date, band)); + const rows = bars.map((bar) => ({ + date: bar.to, + event: "Varroa", + value: [`${formatMitesPerDay(bar.perDay)} Milben/Tag`, bar.label ?? (bar.light ? LIGHT_WORDS[bar.light] : "")] + .filter(Boolean) + .join(" · "), + })); + return { mites: { bars, threshold: band?.redAbove }, rows }; +} + +function treatmentSpans(treatments: readonly Treatment[]) { + return treatments + .map((treatment) => { + const from = dayOf(treatment.startDate ?? treatment.date); + return { from, to: treatment.endDate ? dayOf(treatment.endDate) : from, product: treatment.product }; + }) + .sort((left, right) => left.from.localeCompare(right.from)); +} + +function queenSpans(history: ColonyHistory | undefined, queens: readonly Queen[]): QueenSpan[] { + const byId = new Map(queens.map((queen) => [queen.id, queen])); + return [...(history?.queenHistory ?? [])] + .sort((left, right) => left.startDate.localeCompare(right.startDate)) + .flatMap((entry) => { + const queen = byId.get(entry.queenId); + if (!queen) return []; + return [{ + from: dayOf(entry.startDate), + to: entry.endDate ? dayOf(entry.endDate) : null, + code: queen.code, + ...queenColorOf(queen.markingColor), + hatchYear: queen.hatchYear, + }]; + }); +} + +/** Everything that becomes an icon in the events column and a row in the table. */ +function treatmentRow(span: LifelineModel["treatments"][number], date: string): Happening { + return { + date, kind: "treatments", event: `Behandlung ${span.product}`, + value: span.to === span.from ? formatDayMonth(span.from) : `${formatDayMonth(span.from)}–${formatDayMonth(span.to)}`, + }; +} + +function happenings(input: LifelineInput, treatments: LifelineModel["treatments"]): Happening[] { + const history = input.history; + const lineage = [...(history?.lineageAsSource ?? []), ...(history?.lineageAsResult ?? [])]; + return [ + ...(input.feedings ?? []).map((feeding) => ({ + date: dayOf(feeding.date), kind: "feedings" as const, event: "Fütterung", + value: [`${feeding.amount} ${feeding.unit}`, feeding.product].filter(Boolean).join(" · "), + })), + ...(input.swarmChecks ?? []).map((check) => ({ + date: dayOf(check.date), kind: "swarm-control" as const, event: "Schwarmkontrolle", + value: check.queenCellsFound !== null ? `${check.queenCellsFound} Weiselzellen` : "", + })), + ...(history?.apiaryHistory ?? []).map((move) => ({ + date: dayOf(move.movedAt), kind: "apiaries" as const, event: "Umsetzen", value: move.notes ?? "", + })), + ...lineage.map((entry) => ({ + date: dayOf(entry.date), kind: "colonies" as const, event: entry.type === "SPLIT" ? "Ableger" : "Vereinigung", value: "", + })), + ...(history?.queenHistory ?? []).map((entry) => { + const queen = input.queens?.find((candidate) => candidate.id === entry.queenId); + const value = queen + ? [queen.hatchYear ? `Schlupf ${queen.hatchYear}` : "", queenColorOf(queen.markingColor).colorWord] + : []; + return { + date: dayOf(entry.startDate), kind: "queens" as const, + event: queen ? `Königin ${queen.code}` : "Königinwechsel", value: value.filter(Boolean).join(" · "), + }; + }), + ...treatments.map((span) => treatmentRow(span, span.from)), + ]; +} + +function eventsOf(list: readonly Happening[]): LifelineModel["events"] { + const days = new Map(); + for (const { date, kind } of list) { + const day = days.get(date); + if (!day) days.set(date, { kind, count: 1 }); + else days.set(date, { count: day.count + 1, kind: EVENT_ORDER.indexOf(kind) < EVENT_ORDER.indexOf(day.kind) ? kind : day.kind }); + } + return [...days].map(([date, day]) => ({ date, ...day })).sort(byDate); +} + +function futureOf(input: LifelineInput, today: string, to: string): LifelineModel["future"] { + const candidates = input.nextSteps + ? input.nextSteps.steps.flatMap((step) => (step.dueOn ? [{ dueOn: step.dueOn, title: step.title }] : [])) + : (input.tasks ?? []) + .filter((task) => (task.status === "OPEN" || task.status === "IN_PROGRESS") && task.dueDate !== null) + .filter((task) => task.relatedType === "COLONY" && task.relatedId === input.colony.id) + .map((task) => ({ dueOn: dayOf(task.dueDate as string), title: task.title })) + .filter(({ dueOn }) => dueOn >= today); + // An overdue next step is still the next thing to do: drawn at today, never in the past. + return candidates + .filter(({ dueOn }) => dueOn <= to) + .map(({ dueOn, title }) => ({ date: dueOn < today ? today : dueOn, dueOn, overdue: dueOn < today, title })) + .sort(byDate) + .slice(0, MAX_FUTURE); +} + +function describe(model: Omit): string { + const sentences = [`Volk ${model.colonyCode} seit ${formatDay(model.startDate)}.`]; + const points = model.strength?.points ?? []; + const last = points.at(-1); + if (last) { + const top = points.reduce((best, point) => (point.frames > best.frames ? point : best)); + const month = MONTH.format(new Date(`${top.date}T00:00:00Z`)); + sentences.push(`Stärke zuletzt ${last.frames} Waben am ${formatDayMonth(last.date)}, höchstens ${top.frames} Waben im ${month}.`); + } + const bar = model.mites?.bars.at(-1); + if (bar) { + const word = bar.label ?? (bar.light ? LIGHT_WORDS[bar.light] : null); + sentences.push(`Milben zuletzt ${formatMitesPerDay(bar.perDay)} pro Tag am ${formatDayMonth(bar.to)}${word ? ` – ${word}` : ""}.`); + } + const queen = model.queen?.spans.at(-1); + const count = model.treatments.length; + const closing = [ + queen ? [`Königin ${queen.code}`, queen.hatchYear ? `Schlupf ${queen.hatchYear}` : "", queen.colorWord, `seit ${formatDay(queen.from)}`].filter(Boolean).join(", ") : "", + count > 0 ? `${count} ${count === 1 ? "Behandlung" : "Behandlungen"} in diesem Ausschnitt` : "", + ].filter(Boolean); + if (closing.length > 0) sentences.push(`${closing.join("; ")}.`); + return sentences.join(" "); +} + +/** The colony's life on one time axis (spec 8.1): a pure model, the view draws it. */ +export function buildLifeline(input: LifelineInput, today: string, options: LifelineOptions = {}): LifelineModel { + const since = options.strengthInFramesSince ?? STRENGTH_IN_FRAMES_SINCE; + const startDate = lifeStart(input); + const yearAgo = yearBefore(today); + const from = options.showAll || startDate > yearAgo ? startDate : yearAgo; + const to = addDays(today, FUTURE_DAYS); + const inWindow = (day: string) => day >= from && day <= to; + const overlaps = (span: { from: string; to: string | null }) => span.from <= to && (span.to ?? today) >= from; + + const strength = input.inspections ? strengthTrack(input.inspections, since, inWindow) : undefined; + const mites = input.varroa ? mitesTrack(input.varroa, input.varroaBand, inWindow) : undefined; + const treatments = treatmentSpans(input.treatments ?? []).filter(overlaps); + const spans = input.queens ? queenSpans(input.history, input.queens) : []; + const listed = happenings(input, treatments).filter(({ date }) => inWindow(date)); + // A treatment running into the window gets its row at the window start, but no icon. + const runningIn = treatments.filter((span) => span.from < from).map((span) => treatmentRow(span, from)); + const future = futureOf(input, today, to); + + const model = { + colonyCode: input.colony.code, + startDate, + from, + to, + strength: strength?.strength, + mites: mites?.mites, + treatments, + queen: input.queens ? { spans: spans.filter(overlaps) } : undefined, + events: eventsOf(listed), + future, + withdrawalUntil: input.nextSteps?.withdrawalUntil ?? null, + missing: ([ + ["strength", input.inspections], + ["mites", input.varroa], + ["treatments", input.treatments], + ["queen", input.queens], + ] as const).flatMap(([track, data]) => (data ? [] : [track])), + hasData: (input.inspections?.length ?? 0) + (input.varroa?.length ?? 0) > 0, + hasDataInWindow: (strength?.rows.length ?? 0) + (mites?.rows.length ?? 0) > 0, + }; + const rows = [ + ...future.map(({ date, dueOn, overdue, title }) => ({ + date, event: title, value: overdue ? `fällig seit ${formatDayMonth(dueOn)}` : "geplant", + })), + ...[...listed, ...runningIn].map(({ date, event, value }) => ({ date, event, value })), + ...(strength?.rows ?? []), + ...(mites?.rows ?? []), + ]; + return { ...model, desc: describe(model), table: rows.sort((left, right) => byDate(right, left)) }; +} diff --git a/ApiCore-Frontend/src/lib/colonies/use-colony-lifeline.spec.tsx b/ApiCore-Frontend/src/lib/colonies/use-colony-lifeline.spec.tsx new file mode 100644 index 0000000..dfe2a3d --- /dev/null +++ b/ApiCore-Frontend/src/lib/colonies/use-colony-lifeline.spec.tsx @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider, onlineManager, useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { ApiError } from "@/lib/auth/api-client"; +import { fetchColony, fetchColonyHistory } from "./api"; +import { fetchColonyInspections } from "@/lib/inspections/api"; +import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api"; +import { fetchColonyTreatments } from "@/lib/treatments/api"; +import { fetchColonyFeedings } from "@/lib/feedings/api"; +import { fetchColonySwarmControlChecks } from "@/lib/swarm-control/api"; +import { fetchQueen } from "@/lib/queens/api"; +import { fetchColonyNextSteps } from "@/lib/autopilot/api"; +import { fetchTasks } from "@/lib/tasks/api"; +import { useColonyLifeline } from "./use-colony-lifeline"; + +vi.mock("./api", () => ({ fetchColony: vi.fn(), fetchColonyHistory: vi.fn() })); +vi.mock("@/lib/inspections/api", () => ({ fetchColonyInspections: vi.fn() })); +vi.mock("@/lib/varroa/api", () => ({ fetchColonyVarroaMeasurements: vi.fn() })); +vi.mock("@/lib/treatments/api", () => ({ fetchColonyTreatments: vi.fn() })); +vi.mock("@/lib/feedings/api", () => ({ fetchColonyFeedings: vi.fn() })); +vi.mock("@/lib/swarm-control/api", () => ({ fetchColonySwarmControlChecks: vi.fn() })); +vi.mock("@/lib/queens/api", () => ({ fetchQueen: vi.fn() })); +vi.mock("@/lib/autopilot/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchColonyNextSteps: vi.fn(), +})); +vi.mock("@/lib/tasks/api", () => ({ fetchTasks: vi.fn() })); + +const ID = "c-1"; + +function newClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +function wrapperFor(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function forbidden() { + return Promise.reject(new ApiError(403, "Forbidden")); +} + +describe("useColonyLifeline", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchColony).mockResolvedValue({ id: ID, code: "B3", createdAt: "2026-03-12T00:00:00.000Z" } as never); + vi.mocked(fetchColonyHistory).mockResolvedValue({ + apiaryHistory: [], + queenHistory: [{ id: "h", queenId: "q-1", startDate: "2026-04-20T00:00:00.000Z", endDate: null }], + lineageAsSource: [], + lineageAsResult: [], + }); + vi.mocked(fetchColonyInspections).mockResolvedValue([ + { id: "i", date: "2026-10-02T00:00:00.000Z", colonyStrength: 9, broodFrames: 5 } as never, + ]); + vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([]); + vi.mocked(fetchColonyTreatments).mockResolvedValue([]); + vi.mocked(fetchColonyFeedings).mockResolvedValue([]); + vi.mocked(fetchColonySwarmControlChecks).mockResolvedValue([]); + vi.mocked(fetchQueen).mockResolvedValue({ id: "q-1", code: "Q-12", markingColor: "Blau", hatchYear: 2025 } as never); + vi.mocked(fetchColonyNextSteps).mockResolvedValue({ steps: [], withdrawalUntil: null } as never); + vi.mocked(fetchTasks).mockResolvedValue([]); + }); + + it("useColonyLifeline reuses the page query keys and issues no duplicate request", async () => { + const client = newClient(); + // The keys the colony page's cards use (colony-*.tsx, NextStepsCard). + const pageKeys = [ + ["colonies", ID], + ["colonies", ID, "history"], + ["colonies", ID, "inspections"], + ["colonies", ID, "varroa-measurements"], + ["colonies", ID, "treatments"], + ["colonies", ID, "feedings"], + ["colonies", ID, "swarm-control-checks"], + ["colonies", ID, "next-steps"], + ]; + + // The lifeline mounts together with the cards; a card on the same key shares the request. + const { result } = renderHook( + () => ({ + lifeline: useColonyLifeline(ID), + card: useQuery({ queryKey: ["colonies", ID, "inspections"], queryFn: () => fetchColonyInspections(ID) }), + }), + { wrapper: wrapperFor(client) }, + ); + await waitFor(() => expect(result.current.lifeline.model).toBeDefined()); + expect(result.current.card.data).toHaveLength(1); + + const keys = client + .getQueryCache() + .getAll() + .filter((query) => query.state.dataUpdatedAt > 0) + .map((query) => JSON.stringify(query.queryKey)); + expect(keys.sort()).toEqual([...pageKeys, ["queens", "q-1"]].map((key) => JSON.stringify(key)).sort()); + for (const fetcher of [fetchColony, fetchColonyHistory, fetchColonyInspections, fetchColonyNextSteps, fetchQueen]) { + expect(fetcher).toHaveBeenCalledTimes(1); + } + expect(fetchTasks).not.toHaveBeenCalled(); + expect(result.current.lifeline.model?.queen?.spans[0]).toMatchObject({ code: "Q-12", color: "blue" }); + }); + + it("a 403 on a track drops the track without an error", async () => { + vi.mocked(fetchColonyVarroaMeasurements).mockImplementation(forbidden); + vi.mocked(fetchQueen).mockImplementation(forbidden); + + const { result } = renderHook(() => useColonyLifeline(ID), { wrapper: wrapperFor(newClient()) }); + + await waitFor(() => expect(result.current.model).toBeDefined()); + expect(result.current.isError).toBe(false); + expect(result.current.model?.missing).toEqual(["mites", "queen"]); + }); + + it("any other failure is an error the card can retry", async () => { + vi.mocked(fetchColonyInspections).mockRejectedValueOnce(new ApiError(500, "Boom")); + + const { result } = renderHook(() => useColonyLifeline(ID), { wrapper: wrapperFor(newClient()) }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.model).toBeUndefined(); + + result.current.refetch(); + await waitFor(() => expect(result.current.model).toBeDefined()); + expect(result.current.isError).toBe(false); + }); + + it("without next steps (old backend) the future comes from the open tasks", async () => { + vi.mocked(fetchColonyNextSteps).mockRejectedValue(new ApiError(404, "Not Found")); + const soon = new Date(Date.now() + 3 * 86_400_000).toISOString().slice(0, 10); + vi.mocked(fetchTasks).mockResolvedValue([ + { id: "t", title: "Füttern", status: "OPEN", relatedType: "COLONY", relatedId: ID, dueDate: `${soon}T00:00:00.000Z` } as never, + ]); + + const { result } = renderHook(() => useColonyLifeline(ID), { wrapper: wrapperFor(newClient()) }); + + await waitFor(() => expect(result.current.model?.future).toEqual([{ date: soon, dueOn: soon, overdue: false, title: "Füttern" }])); + expect(result.current.isError).toBe(false); + }); + + it("asks for at most five queens, the latest ones", async () => { + vi.mocked(fetchColonyHistory).mockResolvedValue({ + apiaryHistory: [], + queenHistory: Array.from({ length: 7 }, (_, index) => ({ + id: `h${index}`, + queenId: `q-${index}`, + startDate: `202${index}-04-01T00:00:00.000Z`, + endDate: null, + })), + lineageAsSource: [], + lineageAsResult: [], + }); + + const { result } = renderHook(() => useColonyLifeline(ID), { wrapper: wrapperFor(newClient()) }); + + await waitFor(() => expect(result.current.model).toBeDefined()); + expect(vi.mocked(fetchQueen).mock.calls.map(([id]) => id).sort()).toEqual(["q-2", "q-3", "q-4", "q-5", "q-6"]); + }); + + it("keeps the same model between renders while the data stays the same", async () => { + const { result, rerender } = renderHook(() => useColonyLifeline(ID), { wrapper: wrapperFor(newClient()) }); + await waitFor(() => expect(result.current.model).toBeDefined()); + const first = result.current.model; + + rerender(); + + expect(result.current.model).toBe(first); + }); + + it("offline without cached data it is not loading forever but says it is offline", async () => { + onlineManager.setOnline(false); + try { + const { result } = renderHook(() => useColonyLifeline(ID), { wrapper: wrapperFor(newClient()) }); + + await waitFor(() => expect(result.current.isOffline).toBe(true)); + expect(result.current.isPending).toBe(false); + expect(result.current.isError).toBe(false); + expect(result.current.model).toBeUndefined(); + } finally { + onlineManager.setOnline(true); + } + }); +}); diff --git a/ApiCore-Frontend/src/lib/colonies/use-colony-lifeline.ts b/ApiCore-Frontend/src/lib/colonies/use-colony-lifeline.ts new file mode 100644 index 0000000..dc54de0 --- /dev/null +++ b/ApiCore-Frontend/src/lib/colonies/use-colony-lifeline.ts @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { useQueries, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; +import { ApiError } from "@/lib/auth/api-client"; +import { colonyNextStepsKey, fetchColonyNextSteps } from "@/lib/autopilot/api"; +import { todayLocalIsoDate } from "@/lib/dates"; +import { fetchColonyFeedings } from "@/lib/feedings/api"; +import { fetchColonyInspections } from "@/lib/inspections/api"; +import { fetchQueen } from "@/lib/queens/api"; +import type { Queen } from "@/lib/queens/types"; +import { fetchColonySwarmControlChecks } from "@/lib/swarm-control/api"; +import { fetchTasks } from "@/lib/tasks/api"; +import { fetchColonyTreatments } from "@/lib/treatments/api"; +import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api"; +import { fetchColony, fetchColonyHistory } from "./api"; +import { buildLifeline, type LifelineModel, type LifelineOptions } from "./lifeline"; + +const MAX_QUEENS = 5; + +export interface ColonyLifeline { + model: LifelineModel | undefined; + isPending: boolean; + isError: boolean; + /** Offline with nothing cached: no model, but no endless skeleton either. */ + isOffline: boolean; + refetch(): void; +} + +function isForbidden(query: UseQueryResult): boolean { + return query.error instanceof ApiError && query.error.status === 403; +} + +/** A 403 drops the track (undefined), any other failure is an error of the whole lifeline. */ +function hardError(query: UseQueryResult): boolean { + return query.isError && !query.data && !isForbidden(query); +} + +function isLoading(query: UseQueryResult): boolean { + return query.isPending && query.fetchStatus === "fetching"; +} + +function isPaused(query: UseQueryResult): boolean { + return query.isPending && query.fetchStatus === "paused"; +} + +/** Plain values only, so the combined result is structurally shared between renders. */ +function combineQueens(results: UseQueryResult[]) { + return { + list: results.flatMap((query) => (query.data ? [query.data] : [])), + forbidden: results.some(isForbidden), + loading: results.some(isLoading), + paused: results.some(isPaused), + failed: results.some(hardError), + }; +} + +/** + * Collects the lifeline's data under the keys the colony page's cards already + * use, so the page asks the server once per resource (spec 8.1). + */ +export function useColonyLifeline(colonyId: string, options: LifelineOptions = {}): ColonyLifeline { + const { showAll, strengthInFramesSince } = options; + const queryClient = useQueryClient(); + const colony = useQuery({ queryKey: ["colonies", colonyId], queryFn: () => fetchColony(colonyId) }); + const history = useQuery({ queryKey: ["colonies", colonyId, "history"], queryFn: () => fetchColonyHistory(colonyId) }); + const tracks = useQueries({ + queries: [ + { queryKey: ["colonies", colonyId, "inspections"], queryFn: () => fetchColonyInspections(colonyId) }, + { queryKey: ["colonies", colonyId, "varroa-measurements"], queryFn: () => fetchColonyVarroaMeasurements(colonyId) }, + { queryKey: ["colonies", colonyId, "treatments"], queryFn: () => fetchColonyTreatments(colonyId) }, + { queryKey: ["colonies", colonyId, "feedings"], queryFn: () => fetchColonyFeedings(colonyId) }, + { queryKey: ["colonies", colonyId, "swarm-control-checks"], queryFn: () => fetchColonySwarmControlChecks(colonyId) }, + ], + }); + const [inspections, varroa, treatments, feedings, swarmChecks] = tracks; + const nextSteps = useQuery({ queryKey: colonyNextStepsKey(colonyId), queryFn: () => fetchColonyNextSteps(colonyId) }); + // Old backend without next steps: the open tasks, under the dashboard's key. + const usesTasks = nextSteps.isError && !nextSteps.data; + const tasks = useQuery({ queryKey: ["tasks", "dashboard"], queryFn: () => fetchTasks({}), enabled: usesTasks }); + const queenIds = [ + ...new Set( + [...(history.data?.queenHistory ?? [])] + .sort((left, right) => right.startDate.localeCompare(left.startDate)) + .slice(0, MAX_QUEENS) + .map((entry) => entry.queenId), + ), + ]; + const queens = useQueries({ + queries: queenIds.map((queenId) => ({ queryKey: ["queens", queenId], queryFn: () => fetchQueen(queenId) })), + combine: combineQueens, + }); + + const required = [colony, history, ...tracks]; + const isPending = + required.some(isLoading) || queens.loading || isLoading(nextSteps) || (usesTasks && isLoading(tasks)); + const isOffline = !isPending && (required.some(isPaused) || queens.paused); + const isError = required.some(hardError) || queens.failed; + const ready = !isPending && !isError && !isOffline; + const today = todayLocalIsoDate(); + + const model = useMemo( + () => + ready && colony.data + ? buildLifeline( + { + colony: colony.data, + history: history.data, + inspections: inspections.data, + varroa: varroa.data, + treatments: treatments.data, + feedings: feedings.data, + swarmChecks: swarmChecks.data, + queens: queens.forbidden ? undefined : queens.list, + nextSteps: nextSteps.data, + tasks: tasks.data, + }, + today, + { showAll, strengthInFramesSince }, + ) + : undefined, + [ready, colony.data, history.data, inspections.data, varroa.data, treatments.data, feedings.data, + swarmChecks.data, queens.forbidden, queens.list, nextSteps.data, tasks.data, today, showAll, + strengthInFramesSince], + ); + + return { + model, + isPending, + isError, + isOffline, + refetch: () => { + for (const query of [...required, nextSteps, tasks]) if (query.isError) void query.refetch(); + void queryClient.refetchQueries({ + predicate: (query) => + query.queryKey[0] === "queens" && queenIds.includes(String(query.queryKey[1])) && query.state.status === "error", + }); + }, + }; +} diff --git a/ApiCore-Frontend/src/lib/varroa/bands.ts b/ApiCore-Frontend/src/lib/varroa/bands.ts index 618929b..ea3159a 100644 --- a/ApiCore-Frontend/src/lib/varroa/bands.ts +++ b/ApiCore-Frontend/src/lib/varroa/bands.ts @@ -67,7 +67,7 @@ const MIN_SAMPLE_BEES = 1; const MAX_SAMPLE_BEES = 2000; export const SAMPLE_BEES_ERROR = "Bitte 1 bis 2000 Bienen angeben."; -const LIGHT_WORDS: Record = { +export const LIGHT_WORDS: Record = { green: "Gut", yellow: "Achtung", red: "Kritisch",