feat(t5): Bienenjahr-Rad als Modell, Saison-Standards (T5b B6)
- Typen für die Saison-Zusätze aus /autopilot/today (optional, ältere Backends laufen weiter) und SEASON_DEFAULTS, per Test an die Backend- Configs gebunden. - buildYearWheel: Ring der sechs Abschnitte ab dem aktuellen, spätere nie vor morgen, Bezugsstand mit gemeldet am und Hinweis, Abweichungen mit seit und Quelle, Fenster Schwarmzeit/Einwinterung/Winterbehandlung auch für das Folgejahr, Aufgaben je Woche bis 51 Wochen voraus. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
15e671e921
commit
f9c49074d1
5 files changed
+663
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
import type { PhaseSource, PhenologyPhase, SeasonNote } from "@/lib/phenology/types";
|
||||||
import type { QueenStatus } from "@/lib/queens/types";
|
import type { QueenStatus } from "@/lib/queens/types";
|
||||||
import type { SuggestionIntent } from "@/lib/suggestions/types";
|
import type { SuggestionIntent } from "@/lib/suggestions/types";
|
||||||
|
|
||||||
@@ -72,10 +73,25 @@ export interface InspectionDueApiary {
|
|||||||
colonies: DueColony[];
|
colonies: DueColony[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The optional fields are missing from older backends (before T4 / T5 Task B5).
|
||||||
export interface SeasonOfApiary {
|
export interface SeasonOfApiary {
|
||||||
apiaryId: string;
|
apiaryId: string;
|
||||||
apiaryName: string;
|
apiaryName: string;
|
||||||
segment: SeasonSegment;
|
segment: SeasonSegment;
|
||||||
|
phase?: PhenologyPhase | null;
|
||||||
|
phaseSource?: PhaseSource | null;
|
||||||
|
/** YYYY-MM-DD; the day the current segment began. */
|
||||||
|
segmentSince?: string;
|
||||||
|
/** YYYY-MM-DD; the day of the phase report the phase rests on. */
|
||||||
|
reportedOn?: string | null;
|
||||||
|
note?: SeasonNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The season settings of the PHENOLOGY and SEASON_AUTOPILOT rules; days as 'MM-DD'. */
|
||||||
|
export interface SeasonCalendar {
|
||||||
|
calendarFallback: Record<PhenologyPhase, string>;
|
||||||
|
swarmSeason: { from: PhenologyPhase; until: PhenologyPhase };
|
||||||
|
winterVarroaWindow: { from: string; to: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutopilotToday {
|
export interface AutopilotToday {
|
||||||
@@ -84,6 +100,7 @@ export interface AutopilotToday {
|
|||||||
suggestionsOpen: number;
|
suggestionsOpen: number;
|
||||||
seasons: SeasonOfApiary[];
|
seasons: SeasonOfApiary[];
|
||||||
tasksVisible: boolean;
|
tasksVisible: boolean;
|
||||||
|
seasonCalendar?: SeasonCalendar;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CleanupOutcome = "CANCEL" | "DONE" | "KEEP";
|
export type CleanupOutcome = "CANCEL" | "DONE" | "KEEP";
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PHENOLOGY_PHASES } from "@/lib/phenology/types";
|
||||||
|
import { SEASON_DEFAULTS } from "./season-defaults";
|
||||||
|
|
||||||
|
// Relative to this file. The backend is absent in the frontend-only Docker
|
||||||
|
// build; the pin then runs in the full monorepo checks only.
|
||||||
|
const RULE_CONFIG = resolve(__dirname, "../../../../ApiCore-Backend/src/automation/rule-config");
|
||||||
|
const PHENOLOGY = resolve(RULE_CONFIG, "phenology.config.ts");
|
||||||
|
const SEASON_AUTOPILOT = resolve(RULE_CONFIG, "season-autopilot.config.ts");
|
||||||
|
const hasBackend = existsSync(PHENOLOGY) && existsSync(SEASON_AUTOPILOT);
|
||||||
|
|
||||||
|
describe("SEASON_DEFAULTS", () => {
|
||||||
|
it("names every phase once with an MM-DD day in phase order", () => {
|
||||||
|
const days = PHENOLOGY_PHASES.map((phase) => SEASON_DEFAULTS.calendarFallback[phase]);
|
||||||
|
expect(days.every((day) => /^\d{2}-\d{2}$/.test(day))).toBe(true);
|
||||||
|
expect([...days].sort()).toEqual(days);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(!hasBackend)("defaults equal the T4 PHENOLOGY and SEASON_AUTOPILOT defaults", () => {
|
||||||
|
const phenology = readFileSync(PHENOLOGY, "utf-8");
|
||||||
|
const calendar = phenology.match(/calendarFallback: \{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
const backendDays = Object.fromEntries(
|
||||||
|
[...calendar.matchAll(/(\w+): '(\d{2}-\d{2})'/g)].map(([, phase, day]) => [phase, day]),
|
||||||
|
);
|
||||||
|
expect(SEASON_DEFAULTS.calendarFallback).toEqual(backendDays);
|
||||||
|
|
||||||
|
const swarm = phenology.match(/swarmSeason: \{ from: '(\w+)', until: '(\w+)' \}/);
|
||||||
|
expect(SEASON_DEFAULTS.swarmSeason).toEqual({ from: swarm?.[1], until: swarm?.[2] });
|
||||||
|
|
||||||
|
const autopilot = readFileSync(SEASON_AUTOPILOT, "utf-8");
|
||||||
|
const winter = autopilot.match(/winterVarroaWindow: \{ from: '([\d-]+)', to: '([\d-]+)' \}/);
|
||||||
|
expect(SEASON_DEFAULTS.winterVarroaWindow).toEqual({ from: winter?.[1], to: winter?.[2] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SeasonCalendar } from "@/lib/autopilot/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The T4 defaults of PHENOLOGY (calendarFallback, swarmSeason) and
|
||||||
|
* SEASON_AUTOPILOT (winterVarroaWindow). The wheel uses them when the backend
|
||||||
|
* sends no seasonCalendar; season-defaults.spec pins them to the backend.
|
||||||
|
*/
|
||||||
|
export const SEASON_DEFAULTS: SeasonCalendar = {
|
||||||
|
calendarFallback: {
|
||||||
|
SALWEIDE: "03-20",
|
||||||
|
KIRSCHE: "04-15",
|
||||||
|
APFEL: "04-28",
|
||||||
|
HOLUNDER: "05-30",
|
||||||
|
LINDE: "06-20",
|
||||||
|
TRACHTENDE: "08-01",
|
||||||
|
HERBST: "09-15",
|
||||||
|
WINTER: "11-15",
|
||||||
|
},
|
||||||
|
swarmSeason: { from: "KIRSCHE", until: "LINDE" },
|
||||||
|
winterVarroaWindow: { from: "11-15", to: "12-20" },
|
||||||
|
};
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AutopilotToday, SeasonOfApiary } from "@/lib/autopilot/types";
|
||||||
|
import { SEASON_DEFAULTS } from "./season-defaults";
|
||||||
|
import { angleOf, arcsOf, buildYearWheel } from "./year-wheel";
|
||||||
|
|
||||||
|
function season(overrides: Partial<SeasonOfApiary> & Pick<SeasonOfApiary, "segment">): SeasonOfApiary {
|
||||||
|
return {
|
||||||
|
apiaryId: "a1",
|
||||||
|
apiaryName: "Heimstand",
|
||||||
|
phase: null,
|
||||||
|
phaseSource: "CALENDAR",
|
||||||
|
segmentSince: "2026-09-15",
|
||||||
|
reportedOn: null,
|
||||||
|
note: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function autopilot(seasons: SeasonOfApiary[], withCalendar = true): AutopilotToday {
|
||||||
|
return {
|
||||||
|
nextStep: null,
|
||||||
|
inspectionDue: [],
|
||||||
|
suggestionsOpen: 0,
|
||||||
|
seasons,
|
||||||
|
tasksVisible: true,
|
||||||
|
...(withCalendar ? { seasonCalendar: SEASON_DEFAULTS } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const wheel = (seasons: SeasonOfApiary[], today: string, withCalendar = true) =>
|
||||||
|
buildYearWheel({ today: autopilot(seasons, withCalendar), tasks: [] }, today);
|
||||||
|
|
||||||
|
describe("angleOf", () => {
|
||||||
|
it.each([
|
||||||
|
["2028-01-01", -90],
|
||||||
|
["2028-07-01", (360 * 182) / 366 - 90],
|
||||||
|
["2028-12-31", (360 * 365) / 366 - 90],
|
||||||
|
])("angleOf %s in a leap year", (date, angle) => {
|
||||||
|
expect(angleOf(date)).toBeCloseTo(angle, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 365 days outside a leap year", () => {
|
||||||
|
expect(angleOf("2026-12-31")).toBeCloseTo((360 * 364) / 365 - 90, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("arcsOf", () => {
|
||||||
|
it("a range inside one year is one arc", () => {
|
||||||
|
expect(arcsOf("2026-09-15", "2026-11-15")).toEqual([
|
||||||
|
{ startAngle: angleOf("2026-09-15"), endAngle: angleOf("2026-11-15") },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("winter rest over the year change is two arcs", () => {
|
||||||
|
expect(arcsOf("2026-11-15", "2027-03-20")).toEqual([
|
||||||
|
{ startAngle: angleOf("2026-11-15"), endAngle: 270 },
|
||||||
|
{ startAngle: -90, endAngle: angleOf("2027-03-20") },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildYearWheel", () => {
|
||||||
|
it("in mid October the calendar puts the wheel in autumn with winter rest next", () => {
|
||||||
|
const model = wheel([season({ segment: "HERBST" })], "2026-10-14");
|
||||||
|
expect(model.year).toBe(2026);
|
||||||
|
expect(model.today).toBe("2026-10-14");
|
||||||
|
expect(model.segments.map((s) => [s.key, s.from, s.to, s.current, s.estimated])).toEqual([
|
||||||
|
["HERBST", "2026-09-15", "2026-11-15", true, false],
|
||||||
|
["WINTERRUHE", "2026-11-15", "2027-03-20", false, true],
|
||||||
|
["FRUEHJAHR", "2027-03-20", "2027-04-15", false, true],
|
||||||
|
["SCHWARMZEIT", "2027-04-15", "2027-06-20", false, true],
|
||||||
|
["SOMMER", "2027-06-20", "2027-08-01", false, true],
|
||||||
|
["SPAETSOMMER", "2027-08-01", "2027-09-15", false, true],
|
||||||
|
]);
|
||||||
|
expect(model.segments.map((s) => s.source)).toEqual([
|
||||||
|
"CALENDAR", "CALENDAR", "CALENDAR", "CALENDAR", "CALENDAR", "CALENDAR",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the current segment starts at segmentSince (cherry reported on 05.04. gives swarm season from 05.04.)", () => {
|
||||||
|
const model = wheel(
|
||||||
|
[season({ segment: "SCHWARMZEIT", phase: "KIRSCHE", phaseSource: "REPORT", segmentSince: "2026-04-05", reportedOn: "2026-04-05" })],
|
||||||
|
"2026-04-20",
|
||||||
|
);
|
||||||
|
expect(model.segments[0]).toEqual({
|
||||||
|
key: "SCHWARMZEIT",
|
||||||
|
from: "2026-04-05",
|
||||||
|
to: "2026-06-20",
|
||||||
|
source: "REPORT",
|
||||||
|
current: true,
|
||||||
|
estimated: false,
|
||||||
|
});
|
||||||
|
// The ring closes where it began: spring ends at the reported day.
|
||||||
|
expect(model.segments[5]).toMatchObject({ key: "FRUEHJAHR", from: "2027-03-20", to: "2027-04-05" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("winter rest in January began the year before and spring comes next", () => {
|
||||||
|
const model = wheel([season({ segment: "WINTERRUHE", segmentSince: "2025-11-15" })], "2026-01-10");
|
||||||
|
expect(model.segments[0]).toMatchObject({ key: "WINTERRUHE", from: "2025-11-15", to: "2026-03-20", current: true });
|
||||||
|
expect(model.segments[1]).toMatchObject({ key: "FRUEHJAHR", from: "2026-03-20", to: "2026-04-15" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a late bloom makes the next segment „ab morgen, noch offen“", () => {
|
||||||
|
const model = wheel([season({ segment: "FRUEHJAHR", segmentSince: "2026-03-20" })], "2026-04-20");
|
||||||
|
expect(model.segments[0]).toMatchObject({ key: "FRUEHJAHR", from: "2026-03-20", to: "2026-04-21" });
|
||||||
|
expect(model.segments[1]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-21", to: "2026-06-20", estimated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("segments are gapless around the ring", () => {
|
||||||
|
const { segments } = wheel([season({ segment: "SOMMER", segmentSince: "2026-06-25" })], "2026-07-01");
|
||||||
|
for (let i = 1; i < segments.length; i += 1) {
|
||||||
|
expect(segments[i].from).toBe(segments[i - 1].to);
|
||||||
|
}
|
||||||
|
expect(segments[5].to).toBe("2027-06-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("windows are gapless and do not overlap", () => {
|
||||||
|
const { windows } = wheel([season({ segment: "HERBST" })], "2026-10-14");
|
||||||
|
expect(windows).toEqual([
|
||||||
|
{ key: "TRACHT", from: "2026-03-20", to: "2026-08-01" },
|
||||||
|
{ key: "VARROA_SUMMER", from: "2026-08-01", to: "2026-09-15" },
|
||||||
|
{ key: "EINWINTERUNG", from: "2026-09-15", to: "2026-11-15" },
|
||||||
|
{ key: "VARROA_WINTER", from: "2026-11-15", to: "2026-12-20" },
|
||||||
|
]);
|
||||||
|
for (let i = 1; i < windows.length; i += 1) {
|
||||||
|
expect(windows[i].from).toBe(windows[i - 1].to);
|
||||||
|
expect(windows[i].from < windows[i].to).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an earlier winter treatment cuts the wintering window short instead of overlapping", () => {
|
||||||
|
const today: AutopilotToday = {
|
||||||
|
...autopilot([season({ segment: "HERBST" })]),
|
||||||
|
seasonCalendar: { ...SEASON_DEFAULTS, winterVarroaWindow: { from: "11-01", to: "12-10" } },
|
||||||
|
};
|
||||||
|
const { windows } = buildYearWheel({ today, tasks: [] }, "2026-10-14");
|
||||||
|
expect(windows.slice(2)).toEqual([
|
||||||
|
{ key: "EINWINTERUNG", from: "2026-09-15", to: "2026-11-01" },
|
||||||
|
{ key: "VARROA_WINTER", from: "2026-11-01", to: "2026-12-10" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the reference apiary is the majority, a tie takes the more advanced segment", () => {
|
||||||
|
const majority = wheel(
|
||||||
|
[
|
||||||
|
season({ apiaryId: "a1", apiaryName: "Obstwiese", segment: "SOMMER", segmentSince: "2026-06-20" }),
|
||||||
|
season({ apiaryId: "a2", apiaryName: "Heimstand", segment: "SCHWARMZEIT", segmentSince: "2026-04-15" }),
|
||||||
|
season({ apiaryId: "a3", apiaryName: "Waldrand", segment: "SCHWARMZEIT", segmentSince: "2026-04-18" }),
|
||||||
|
],
|
||||||
|
"2026-06-22",
|
||||||
|
);
|
||||||
|
expect(majority.reference).toMatchObject({ apiaryId: "a2", apiaryName: "Heimstand" });
|
||||||
|
expect(majority.segments[0]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-15" });
|
||||||
|
|
||||||
|
const tie = wheel(
|
||||||
|
[
|
||||||
|
season({ apiaryId: "a1", apiaryName: "Südhang", segment: "FRUEHJAHR", segmentSince: "2026-03-20" }),
|
||||||
|
season({ apiaryId: "a2", apiaryName: "Heimstand", segment: "SCHWARMZEIT", segmentSince: "2026-04-15" }),
|
||||||
|
],
|
||||||
|
"2026-04-16",
|
||||||
|
);
|
||||||
|
expect(tie.reference).toMatchObject({ apiaryId: "a2", apiaryName: "Heimstand" });
|
||||||
|
|
||||||
|
// Over the year change spring is ahead of winter rest.
|
||||||
|
const spring = wheel(
|
||||||
|
[
|
||||||
|
season({ apiaryId: "a1", apiaryName: "Waldrand", segment: "WINTERRUHE", segmentSince: "2025-11-15" }),
|
||||||
|
season({ apiaryId: "a2", apiaryName: "Heimstand", segment: "FRUEHJAHR", segmentSince: "2026-03-20" }),
|
||||||
|
],
|
||||||
|
"2026-03-25",
|
||||||
|
);
|
||||||
|
expect(spring.reference?.apiaryName).toBe("Heimstand");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deviations name the apiaries that differ", () => {
|
||||||
|
const model = wheel(
|
||||||
|
[
|
||||||
|
season({ apiaryId: "a1", apiaryName: "Heimstand", segment: "SCHWARMZEIT", segmentSince: "2026-04-15" }),
|
||||||
|
season({ apiaryId: "a2", apiaryName: "Obstwiese", segment: "SCHWARMZEIT", segmentSince: "2026-04-15" }),
|
||||||
|
season({ apiaryId: "a3", apiaryName: "Südhang", segment: "FRUEHJAHR", phaseSource: "GTS", segmentSince: "2026-03-22" }),
|
||||||
|
],
|
||||||
|
"2026-04-16",
|
||||||
|
);
|
||||||
|
expect(model.deviations).toEqual([
|
||||||
|
{ apiaryName: "Südhang", segment: "FRUEHJAHR", since: "2026-03-22", source: "GTS" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the reference carries reportedOn and note for the centre line", () => {
|
||||||
|
const model = wheel(
|
||||||
|
[
|
||||||
|
season({
|
||||||
|
segment: "SCHWARMZEIT",
|
||||||
|
phaseSource: "REPORT",
|
||||||
|
segmentSince: "2026-04-05",
|
||||||
|
reportedOn: "2026-04-05",
|
||||||
|
note: "WEATHER_GAPS",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"2026-04-20",
|
||||||
|
);
|
||||||
|
expect(model.reference).toEqual({
|
||||||
|
apiaryId: "a1",
|
||||||
|
apiaryName: "Heimstand",
|
||||||
|
reportedOn: "2026-04-05",
|
||||||
|
note: "WEATHER_GAPS",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an older backend with an apiary ahead of the calendar starts its segment today, not a year ago", () => {
|
||||||
|
const old = (apiaryId: string, apiaryName: string, segment: SeasonOfApiary["segment"]) =>
|
||||||
|
({ apiaryId, apiaryName, segment }) as SeasonOfApiary;
|
||||||
|
const model = buildYearWheel(
|
||||||
|
{
|
||||||
|
today: autopilot([old("a1", "Südhang", "FRUEHJAHR"), old("a2", "Heimstand", "SCHWARMZEIT")], false),
|
||||||
|
tasks: [],
|
||||||
|
},
|
||||||
|
"2026-04-10",
|
||||||
|
);
|
||||||
|
expect(model.reference?.apiaryName).toBe("Heimstand");
|
||||||
|
expect(model.segments[0]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-10", to: "2026-06-20" });
|
||||||
|
expect(model.segments[5]).toMatchObject({ key: "FRUEHJAHR", from: "2027-03-20", to: "2027-04-10" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a tie takes the segment further on in the bee year even if it began earlier", () => {
|
||||||
|
const model = wheel(
|
||||||
|
[
|
||||||
|
season({ apiaryId: "a1", apiaryName: "Südhang", segment: "FRUEHJAHR", segmentSince: "2026-04-08" }),
|
||||||
|
season({ apiaryId: "a2", apiaryName: "Heimstand", segment: "SCHWARMZEIT", segmentSince: "2026-04-05" }),
|
||||||
|
],
|
||||||
|
"2026-04-10",
|
||||||
|
);
|
||||||
|
expect(model.reference?.apiaryName).toBe("Heimstand");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale segment never yields a segment that ends before it starts", () => {
|
||||||
|
const { segments } = wheel([season({ segment: "FRUEHJAHR", segmentSince: "2026-03-20" })], "2027-03-25");
|
||||||
|
for (const segment of segments) {
|
||||||
|
expect(segment.from <= segment.to).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a winter treatment set before autumn does not invert the wintering window", () => {
|
||||||
|
const today: AutopilotToday = {
|
||||||
|
...autopilot([season({ segment: "HERBST" })]),
|
||||||
|
seasonCalendar: { ...SEASON_DEFAULTS, winterVarroaWindow: { from: "09-01", to: "10-01" } },
|
||||||
|
};
|
||||||
|
const { windows } = buildYearWheel({ today, tasks: [] }, "2026-10-14");
|
||||||
|
const wintering = windows.find((window) => window.key === "EINWINTERUNG");
|
||||||
|
expect(wintering).toEqual({ key: "EINWINTERUNG", from: "2026-09-15", to: "2026-09-15" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries next year's windows for „Danach …“ after the winter treatment", () => {
|
||||||
|
const model = wheel([season({ segment: "WINTERRUHE", segmentSince: "2026-11-15" })], "2026-12-28");
|
||||||
|
expect(model.nextWindows[0]).toEqual({ key: "TRACHT", from: "2027-03-20", to: "2027-08-01" });
|
||||||
|
expect(model.nextWindows).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("task weeks end 51 weeks ahead so no dot sits on the overdue dot", () => {
|
||||||
|
const model = buildYearWheel(
|
||||||
|
{
|
||||||
|
today: autopilot([season({ segment: "HERBST" })]),
|
||||||
|
tasks: [
|
||||||
|
{ dueDate: "2027-10-05", status: "OPEN" },
|
||||||
|
{ dueDate: "2027-10-06", status: "OPEN" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"2026-10-14",
|
||||||
|
);
|
||||||
|
expect(model.taskWeeks).toEqual([{ weekStart: "2027-10-04", count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tasks are counted per week and overdue ones are collected", () => {
|
||||||
|
const model = buildYearWheel(
|
||||||
|
{
|
||||||
|
today: autopilot([season({ segment: "HERBST" })]),
|
||||||
|
tasks: [
|
||||||
|
{ dueDate: "2026-10-14T00:00:00.000Z", status: "OPEN" },
|
||||||
|
{ dueDate: "2026-10-18", status: "IN_PROGRESS" },
|
||||||
|
{ dueDate: "2026-10-20T00:00:00.000Z", status: "OPEN" },
|
||||||
|
{ dueDate: "2026-10-10T00:00:00.000Z", status: "OPEN" },
|
||||||
|
{ dueDate: "2026-09-01T00:00:00.000Z", status: "OPEN" },
|
||||||
|
{ dueDate: "2026-10-15T00:00:00.000Z", status: "DONE" },
|
||||||
|
{ dueDate: "2026-10-16T00:00:00.000Z", status: "CANCELLED" },
|
||||||
|
{ dueDate: null, status: "OPEN" },
|
||||||
|
// A year ahead would land on today's spot of the wheel.
|
||||||
|
{ dueDate: "2027-10-14T00:00:00.000Z", status: "OPEN" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"2026-10-14",
|
||||||
|
);
|
||||||
|
expect(model.taskWeeks).toEqual([
|
||||||
|
{ weekStart: "2026-10-12", count: 2 },
|
||||||
|
{ weekStart: "2026-10-19", count: 1 },
|
||||||
|
]);
|
||||||
|
expect(model.overdueCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without seasonCalendar the wheel uses the season defaults", () => {
|
||||||
|
// An older backend: no seasonCalendar and no segmentSince, phaseSource or note.
|
||||||
|
const old = buildYearWheel(
|
||||||
|
{
|
||||||
|
today: autopilot(
|
||||||
|
[{ apiaryId: "a1", apiaryName: "Heimstand", segment: "HERBST" } as SeasonOfApiary],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
tasks: [],
|
||||||
|
},
|
||||||
|
"2026-10-14",
|
||||||
|
);
|
||||||
|
const current = wheel([season({ segment: "HERBST" })], "2026-10-14");
|
||||||
|
expect(old.windows).toEqual(current.windows);
|
||||||
|
expect(old.segments).toEqual(current.segments);
|
||||||
|
expect(old.reference).toEqual({ apiaryId: "a1", apiaryName: "Heimstand", reportedOn: null, note: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without autopilot data the calendar alone places the wheel", () => {
|
||||||
|
const model = buildYearWheel({ today: undefined, tasks: [] }, "2026-02-01");
|
||||||
|
expect(model.reference).toBeNull();
|
||||||
|
expect(model.deviations).toEqual([]);
|
||||||
|
expect(model.segments[0]).toMatchObject({
|
||||||
|
key: "WINTERRUHE",
|
||||||
|
from: "2025-11-15",
|
||||||
|
to: "2026-03-20",
|
||||||
|
source: "CALENDAR",
|
||||||
|
current: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import type { AutopilotToday, SeasonCalendar, SeasonOfApiary, SeasonSegment } from "@/lib/autopilot/types";
|
||||||
|
import type { PhaseSource, SeasonNote } from "@/lib/phenology/types";
|
||||||
|
import { isActiveTask } from "@/lib/tasks/today";
|
||||||
|
import type { Task } from "@/lib/tasks/types";
|
||||||
|
import { SEASON_DEFAULTS } from "./season-defaults";
|
||||||
|
|
||||||
|
export interface YearWheelSegment {
|
||||||
|
key: SeasonSegment;
|
||||||
|
/** YYYY-MM-DD, first day. */
|
||||||
|
from: string;
|
||||||
|
/** YYYY-MM-DD, the first day of the next segment. */
|
||||||
|
to: string;
|
||||||
|
source: PhaseSource;
|
||||||
|
current: boolean;
|
||||||
|
/** A calendar boundary, drawn dashed. */
|
||||||
|
estimated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type YearWheelWindowKey = "TRACHT" | "VARROA_SUMMER" | "EINWINTERUNG" | "VARROA_WINTER";
|
||||||
|
|
||||||
|
export interface YearWheelWindow {
|
||||||
|
key: YearWheelWindowKey;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YearWheelModel {
|
||||||
|
year: number;
|
||||||
|
today: string;
|
||||||
|
/** The ring of all six segments, starting with the current one. */
|
||||||
|
segments: YearWheelSegment[];
|
||||||
|
windows: YearWheelWindow[];
|
||||||
|
/** The same windows a year on, for „Danach …“ after the winter treatment. */
|
||||||
|
nextWindows: YearWheelWindow[];
|
||||||
|
/** Open tasks due within 51 weeks from today, per week (Monday). */
|
||||||
|
taskWeeks: { weekStart: string; count: number }[];
|
||||||
|
overdueCount: number;
|
||||||
|
reference: { apiaryId: string; apiaryName: string; reportedOn: string | null; note: SeasonNote } | null;
|
||||||
|
deviations: { apiaryName: string; segment: SeasonSegment; since: string; source: PhaseSource }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YearWheelInput {
|
||||||
|
today: AutopilotToday | undefined;
|
||||||
|
tasks: Pick<Task, "dueDate" | "status">[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segments in the order of the bee year; winter rest runs over the year change. */
|
||||||
|
const CYCLE: readonly SeasonSegment[] = [
|
||||||
|
"FRUEHJAHR",
|
||||||
|
"SCHWARMZEIT",
|
||||||
|
"SOMMER",
|
||||||
|
"SPAETSOMMER",
|
||||||
|
"HERBST",
|
||||||
|
"WINTERRUHE",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
const toTime = (date: string): number => Date.parse(`${date.slice(0, 10)}T00:00:00Z`);
|
||||||
|
const toDay = (time: number): string => new Date(time).toISOString().slice(0, 10);
|
||||||
|
const addDays = (date: string, days: number): string => toDay(toTime(date) + days * DAY_MS);
|
||||||
|
const yearOf = (date: string): number => Number(date.slice(0, 4));
|
||||||
|
const latest = (...dates: string[]): string => dates.reduce((a, b) => (b > a ? b : a));
|
||||||
|
const earliest = (...dates: string[]): string => dates.reduce((a, b) => (b < a ? b : a));
|
||||||
|
|
||||||
|
function nextYear(date: string): string {
|
||||||
|
const day = new Date(toTime(date));
|
||||||
|
return toDay(Date.UTC(day.getUTCFullYear() + 1, day.getUTCMonth(), day.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 360° × (day of year − 1) / days in year − 90°: January on top, clockwise. */
|
||||||
|
export function angleOf(date: string): number {
|
||||||
|
const year = yearOf(date);
|
||||||
|
const dayIndex = (toTime(date) - Date.UTC(year, 0, 1)) / DAY_MS;
|
||||||
|
const daysInYear = (Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / DAY_MS;
|
||||||
|
return (360 * dayIndex) / daysInYear - 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The arcs from `from` up to `to`; over the year change two arcs. */
|
||||||
|
export function arcsOf(from: string, to: string): { startAngle: number; endAngle: number }[] {
|
||||||
|
const arcs =
|
||||||
|
yearOf(to) === yearOf(from)
|
||||||
|
? [{ startAngle: angleOf(from), endAngle: angleOf(to) }]
|
||||||
|
: [
|
||||||
|
{ startAngle: angleOf(from), endAngle: 270 },
|
||||||
|
{ startAngle: -90, endAngle: angleOf(to) },
|
||||||
|
];
|
||||||
|
return arcs.filter((arc) => arc.endAngle > arc.startAngle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 'MM-DD' on which each segment begins by the calendar. */
|
||||||
|
function startDays(calendar: SeasonCalendar): Record<SeasonSegment, string> {
|
||||||
|
const days = calendar.calendarFallback;
|
||||||
|
return {
|
||||||
|
FRUEHJAHR: days.SALWEIDE,
|
||||||
|
SCHWARMZEIT: days[calendar.swarmSeason.from],
|
||||||
|
SOMMER: days[calendar.swarmSeason.until],
|
||||||
|
SPAETSOMMER: days.TRACHTENDE,
|
||||||
|
HERBST: days.HERBST,
|
||||||
|
WINTERRUHE: days.WINTER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When `segment` began by the calendar (older backends send no segmentSince):
|
||||||
|
* its start this year if reached; winter rest before spring began last year;
|
||||||
|
* any other segment not yet due is ahead of the calendar and began today.
|
||||||
|
*/
|
||||||
|
function calendarStart(segment: SeasonSegment, today: string, starts: Record<SeasonSegment, string>): string {
|
||||||
|
const thisYear = `${yearOf(today)}-${starts[segment]}`;
|
||||||
|
if (thisYear <= today) return thisYear;
|
||||||
|
return segment === "WINTERRUHE" ? `${yearOf(today) - 1}-${starts[segment]}` : today;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bee year of a segment: winter rest begun in January to March counts to the year before. */
|
||||||
|
function beeYearOf(segment: SeasonSegment, since: string, starts: Record<SeasonSegment, string>): number {
|
||||||
|
const isEarlyWinter = segment === "WINTERRUHE" && since.slice(5) < starts.FRUEHJAHR;
|
||||||
|
return yearOf(since) - (isEarlyWinter ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The segment the calendar alone puts `today` in; before spring the winter rest. */
|
||||||
|
function calendarSegment(today: string, starts: Record<SeasonSegment, string>): SeasonSegment {
|
||||||
|
const reached = CYCLE.filter((segment) => `${yearOf(today)}-${starts[segment]}` <= today);
|
||||||
|
return reached[reached.length - 1] ?? "WINTERRUHE";
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeasonWithSince = SeasonOfApiary & { since: string };
|
||||||
|
|
||||||
|
/** The segment of most apiaries; on a tie the more advanced one (bee year, then segment order). */
|
||||||
|
function referenceOf(seasons: SeasonWithSince[], starts: Record<SeasonSegment, string>): SeasonWithSince | null {
|
||||||
|
const groups = new Map<SeasonSegment, SeasonWithSince[]>();
|
||||||
|
for (const season of seasons) {
|
||||||
|
groups.set(season.segment, [...(groups.get(season.segment) ?? []), season]);
|
||||||
|
}
|
||||||
|
const advance = (group: SeasonWithSince[]): number =>
|
||||||
|
Math.max(
|
||||||
|
...group.map(
|
||||||
|
(season) => beeYearOf(season.segment, season.since, starts) * CYCLE.length + CYCLE.indexOf(season.segment),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let best: SeasonWithSince[] | null = null;
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
const isBetter =
|
||||||
|
best === null ||
|
||||||
|
group.length > best.length ||
|
||||||
|
(group.length === best.length && advance(group) > advance(best));
|
||||||
|
if (isBetter) best = group;
|
||||||
|
}
|
||||||
|
return best?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ring from the current segment on. Later segments start on their
|
||||||
|
* calendar day, but never before tomorrow: a calendar day already past whose
|
||||||
|
* segment has not come yet (late bloom) opens it "ab morgen, noch offen".
|
||||||
|
*/
|
||||||
|
function ringOf(
|
||||||
|
current: { segment: SeasonSegment; since: string; source: PhaseSource },
|
||||||
|
today: string,
|
||||||
|
starts: Record<SeasonSegment, string>,
|
||||||
|
): YearWheelSegment[] {
|
||||||
|
const first = CYCLE.indexOf(current.segment);
|
||||||
|
const beeYear = beeYearOf(current.segment, current.since, starts);
|
||||||
|
const tomorrow = addDays(today, 1);
|
||||||
|
// The ring closes a year after the current segment began; a stale segment caps the rest there.
|
||||||
|
const ringEnd = nextYear(current.since);
|
||||||
|
const froms = [current.since];
|
||||||
|
for (let step = 1; step < CYCLE.length; step += 1) {
|
||||||
|
const index = first + step;
|
||||||
|
const calendarDay = `${beeYear + Math.floor(index / CYCLE.length)}-${starts[CYCLE[index % CYCLE.length]]}`;
|
||||||
|
froms.push(earliest(latest(calendarDay, tomorrow, froms[step - 1]), ringEnd));
|
||||||
|
}
|
||||||
|
const tos = [...froms.slice(1), ringEnd];
|
||||||
|
return froms.map((from, step) => ({
|
||||||
|
key: CYCLE[(first + step) % CYCLE.length],
|
||||||
|
from,
|
||||||
|
to: tos[step],
|
||||||
|
source: step === 0 ? current.source : "CALENDAR",
|
||||||
|
current: step === 0,
|
||||||
|
estimated: step !== 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tracht, Varroa summer, wintering and Varroa winter of the wheel year, one after another. */
|
||||||
|
function windowsOf(year: number, calendar: SeasonCalendar): YearWheelWindow[] {
|
||||||
|
const days = calendar.calendarFallback;
|
||||||
|
const winter = calendar.winterVarroaWindow;
|
||||||
|
const day = (monthDay: string): string => `${year}-${monthDay}`;
|
||||||
|
return [
|
||||||
|
{ key: "TRACHT", from: day(days.SALWEIDE), to: day(days.TRACHTENDE) },
|
||||||
|
{ key: "VARROA_SUMMER", from: day(days.TRACHTENDE), to: day(days.HERBST) },
|
||||||
|
// An earlier winter treatment ends the wintering window (never before it starts).
|
||||||
|
{
|
||||||
|
key: "EINWINTERUNG",
|
||||||
|
from: day(days.HERBST),
|
||||||
|
to: day(latest(days.HERBST, earliest(winter.from, days.WINTER))),
|
||||||
|
},
|
||||||
|
{ key: "VARROA_WINTER", from: day(winter.from), to: day(winter.to) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasksOf(tasks: YearWheelInput["tasks"], today: string): Pick<YearWheelModel, "taskWeeks" | "overdueCount"> {
|
||||||
|
const dueDays = tasks
|
||||||
|
.filter((task) => isActiveTask(task) && task.dueDate !== null)
|
||||||
|
.map((task) => (task.dueDate as string).slice(0, 10));
|
||||||
|
// 52 weeks less one: a dot a full year ahead would sit on the overdue dot next to the pointer.
|
||||||
|
const end = addDays(today, 51 * 7);
|
||||||
|
const weeks = new Map<string, number>();
|
||||||
|
for (const day of dueDays.filter((due) => due >= today && due < end)) {
|
||||||
|
const monday = addDays(day, -((new Date(toTime(day)).getUTCDay() + 6) % 7));
|
||||||
|
weeks.set(monday, (weeks.get(monday) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
taskWeeks: [...weeks.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([weekStart, count]) => ({ weekStart, count })),
|
||||||
|
overdueCount: dueDays.filter((due) => due < today).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bee-year wheel for `today` (YYYY-MM-DD); older backends fall back to the season defaults. */
|
||||||
|
export function buildYearWheel(input: YearWheelInput, today: string): YearWheelModel {
|
||||||
|
const calendar = input.today?.seasonCalendar ?? SEASON_DEFAULTS;
|
||||||
|
const starts = startDays(calendar);
|
||||||
|
const seasons = (input.today?.seasons ?? []).map((season) => ({
|
||||||
|
...season,
|
||||||
|
since: season.segmentSince ?? calendarStart(season.segment, today, starts),
|
||||||
|
}));
|
||||||
|
const reference = referenceOf(seasons, starts);
|
||||||
|
const segment = reference?.segment ?? calendarSegment(today, starts);
|
||||||
|
const current = {
|
||||||
|
segment,
|
||||||
|
since: reference?.since ?? calendarStart(segment, today, starts),
|
||||||
|
source: reference?.phaseSource ?? "CALENDAR",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
year: yearOf(today),
|
||||||
|
today,
|
||||||
|
segments: ringOf(current, today, starts),
|
||||||
|
windows: windowsOf(yearOf(today), calendar),
|
||||||
|
nextWindows: windowsOf(yearOf(today) + 1, calendar),
|
||||||
|
...tasksOf(input.tasks, today),
|
||||||
|
reference: reference
|
||||||
|
? {
|
||||||
|
apiaryId: reference.apiaryId,
|
||||||
|
apiaryName: reference.apiaryName,
|
||||||
|
reportedOn: reference.reportedOn ?? null,
|
||||||
|
note: reference.note ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
deviations: seasons
|
||||||
|
.filter((season) => season.segment !== segment)
|
||||||
|
.map((season) => ({
|
||||||
|
apiaryName: season.apiaryName,
|
||||||
|
segment: season.segment,
|
||||||
|
since: season.since,
|
||||||
|
source: season.phaseSource ?? "CALENDAR",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user