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:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 16:36:17 +02:00
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 { 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<PhenologyPhase, string>;
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";
@@ -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",
})),
};
}