feat(t5): Lebenslinie als Modell und Daten-Hook (T5b B3)

- 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) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 16:05:27 +02:00
1 parent bcc7cbfd13
commit 2a6f0b684c
5 files changed
+1197 -1

No files matched your search

@@ -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> = {},
): 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> = {}): 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> = {}): 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);
});
});
@@ -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<Record<string, QueenColor>> = {
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<Record<QueenColor, string>> = {
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<Colony, "id" | "code" | "createdAt">;
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<T extends { date: string }>(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<string, { kind: AreaKey; count: number }>();
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<LifelineModel, "desc" | "table">): 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)) };
}
@@ -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<typeof import("@/lib/autopilot/api")>()),
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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
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);
}
});
});
@@ -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<Queen>[]) {
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",
});
},
};
}
+1 -1
View File
@@ -67,7 +67,7 @@ const MIN_SAMPLE_BEES = 1;
const MAX_SAMPLE_BEES = 2000; const MAX_SAMPLE_BEES = 2000;
export const SAMPLE_BEES_ERROR = "Bitte 1 bis 2000 Bienen angeben."; export const SAMPLE_BEES_ERROR = "Bitte 1 bis 2000 Bienen angeben.";
const LIGHT_WORDS: Record<VarroaLight, string> = { export const LIGHT_WORDS: Record<VarroaLight, string> = {
green: "Gut", green: "Gut",
yellow: "Achtung", yellow: "Achtung",
red: "Kritisch", red: "Kritisch",