From f9c49074d149d0ae18a385a17a71878f450c7e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Mon, 19 Oct 2026 16:36:17 +0200 Subject: [PATCH] feat(t5): Bienenjahr-Rad als Modell, Saison-Standards (T5b B6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ApiCore-Frontend/src/lib/autopilot/types.ts | 17 + .../src/lib/season/season-defaults.spec.ts | 36 ++ .../src/lib/season/season-defaults.ts | 21 ++ .../src/lib/season/year-wheel.spec.ts | 329 ++++++++++++++++++ ApiCore-Frontend/src/lib/season/year-wheel.ts | 260 ++++++++++++++ 5 files changed, 663 insertions(+) create mode 100644 ApiCore-Frontend/src/lib/season/season-defaults.spec.ts create mode 100644 ApiCore-Frontend/src/lib/season/season-defaults.ts create mode 100644 ApiCore-Frontend/src/lib/season/year-wheel.spec.ts create mode 100644 ApiCore-Frontend/src/lib/season/year-wheel.ts diff --git a/ApiCore-Frontend/src/lib/autopilot/types.ts b/ApiCore-Frontend/src/lib/autopilot/types.ts index 1cf3d68..59e6960 100644 --- a/ApiCore-Frontend/src/lib/autopilot/types.ts +++ b/ApiCore-Frontend/src/lib/autopilot/types.ts @@ -1,3 +1,4 @@ +import type { PhaseSource, PhenologyPhase, SeasonNote } from "@/lib/phenology/types"; import type { QueenStatus } from "@/lib/queens/types"; import type { SuggestionIntent } from "@/lib/suggestions/types"; @@ -72,10 +73,25 @@ export interface InspectionDueApiary { colonies: DueColony[]; } +// The optional fields are missing from older backends (before T4 / T5 Task B5). export interface SeasonOfApiary { apiaryId: string; apiaryName: string; 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; + swarmSeason: { from: PhenologyPhase; until: PhenologyPhase }; + winterVarroaWindow: { from: string; to: string }; } export interface AutopilotToday { @@ -84,6 +100,7 @@ export interface AutopilotToday { suggestionsOpen: number; seasons: SeasonOfApiary[]; tasksVisible: boolean; + seasonCalendar?: SeasonCalendar; } export type CleanupOutcome = "CANCEL" | "DONE" | "KEEP"; diff --git a/ApiCore-Frontend/src/lib/season/season-defaults.spec.ts b/ApiCore-Frontend/src/lib/season/season-defaults.spec.ts new file mode 100644 index 0000000..431b780 --- /dev/null +++ b/ApiCore-Frontend/src/lib/season/season-defaults.spec.ts @@ -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] }); + }); +}); diff --git a/ApiCore-Frontend/src/lib/season/season-defaults.ts b/ApiCore-Frontend/src/lib/season/season-defaults.ts new file mode 100644 index 0000000..7024a6f --- /dev/null +++ b/ApiCore-Frontend/src/lib/season/season-defaults.ts @@ -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" }, +}; diff --git a/ApiCore-Frontend/src/lib/season/year-wheel.spec.ts b/ApiCore-Frontend/src/lib/season/year-wheel.spec.ts new file mode 100644 index 0000000..ce5c591 --- /dev/null +++ b/ApiCore-Frontend/src/lib/season/year-wheel.spec.ts @@ -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 & Pick): 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, + }); + }); +}); diff --git a/ApiCore-Frontend/src/lib/season/year-wheel.ts b/ApiCore-Frontend/src/lib/season/year-wheel.ts new file mode 100644 index 0000000..3eb7f66 --- /dev/null +++ b/ApiCore-Frontend/src/lib/season/year-wheel.ts @@ -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[]; +} + +/** 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 { + 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): 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): 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 { + 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): SeasonWithSince | null { + const groups = new Map(); + 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, +): 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 { + 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(); + 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", + })), + }; +}