feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers - Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as ColonyCondition with legacy text mapping), split queen source, varroa sample size; migration harness for data migrations - SuggestionsService: propose/refresh/resolve/obsolete/expire, accept/dismiss/reopen incl. bulk, lock order, concurrency e2e - SeasonService: phenology-based segments, inspection/varroa due dates, queen age; TimeEventsStep with JobPartialFailure - Frontend: per-rule settings forms with visible value sources Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { DEFAULT_INSPECTION_INTERVAL_CONFIG } from '../automation/rule-config/inspection-interval.config';
|
||||
import { inspectionDueOn } from './inspection-due';
|
||||
|
||||
const CONFIG = DEFAULT_INSPECTION_INTERVAL_CONFIG;
|
||||
const SCHWARMZEIT = {
|
||||
segment: 'SCHWARMZEIT',
|
||||
springSince: '2026-03-20',
|
||||
} as const;
|
||||
|
||||
describe('inspectionDueOn', () => {
|
||||
it('adds the interval of the current segment to the last inspection', () =>
|
||||
expect(
|
||||
inspectionDueOn(
|
||||
{ createdOn: '2025-06-01', lastInspectedOn: '2026-05-05' },
|
||||
SCHWARMZEIT,
|
||||
CONFIG,
|
||||
),
|
||||
).toEqual({ dueOn: '2026-05-12', firstOfYear: false, intervalDays: 7 }));
|
||||
|
||||
it('uses the interval of SOMMER in SOMMER', () =>
|
||||
expect(
|
||||
inspectionDueOn(
|
||||
{ createdOn: '2025-06-01', lastInspectedOn: '2026-06-25' },
|
||||
{ segment: 'SOMMER', springSince: '2026-03-20' },
|
||||
CONFIG,
|
||||
),
|
||||
).toEqual({ dueOn: '2026-07-09', firstOfYear: false, intervalDays: 14 }));
|
||||
|
||||
it('returns null in WINTERRUHE', () =>
|
||||
expect(
|
||||
inspectionDueOn(
|
||||
{ createdOn: '2025-06-01', lastInspectedOn: '2025-10-01' },
|
||||
{ segment: 'WINTERRUHE', springSince: null },
|
||||
CONFIG,
|
||||
),
|
||||
).toBeNull());
|
||||
|
||||
it('counts from createdAt without any inspection', () =>
|
||||
expect(
|
||||
inspectionDueOn(
|
||||
{ createdOn: '2026-04-20', lastInspectedOn: null },
|
||||
SCHWARMZEIT,
|
||||
CONFIG,
|
||||
),
|
||||
).toEqual({ dueOn: '2026-04-27', firstOfYear: false, intervalDays: 7 }));
|
||||
|
||||
it('is due from the start of FRUEHJAHR when the last inspection was before it (first of the year)', () =>
|
||||
expect(
|
||||
inspectionDueOn(
|
||||
{ createdOn: '2025-06-01', lastInspectedOn: '2025-10-30' },
|
||||
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
|
||||
CONFIG,
|
||||
),
|
||||
).toEqual({ dueOn: '2026-03-20', firstOfYear: true, intervalDays: 14 }));
|
||||
|
||||
it('a colony created before the spring without an inspection is first-of-year due too', () =>
|
||||
expect(
|
||||
inspectionDueOn(
|
||||
{ createdOn: '2025-09-01', lastInspectedOn: null },
|
||||
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
|
||||
CONFIG,
|
||||
),
|
||||
).toMatchObject({ dueOn: '2026-03-20', firstOfYear: true }));
|
||||
|
||||
it('an inspection on the first day of FRUEHJAHR counts as done', () =>
|
||||
expect(
|
||||
inspectionDueOn(
|
||||
{ createdOn: '2025-06-01', lastInspectedOn: '2026-03-20' },
|
||||
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
|
||||
CONFIG,
|
||||
),
|
||||
).toEqual({ dueOn: '2026-04-03', firstOfYear: false, intervalDays: 14 }));
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { addDays } from '../common/dates';
|
||||
import type { InspectionIntervalConfig } from '../automation/rule-config/rule-config.types';
|
||||
import type { SeasonState } from './season-types';
|
||||
|
||||
export interface InspectionDue {
|
||||
dueOn: string;
|
||||
/** Due because the last inspection lies before this spring ("first of the year"). */
|
||||
firstOfYear: boolean;
|
||||
intervalDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The day an inspection is due: the last inspection (else the creation of
|
||||
* the colony) plus the interval of today's segment. A colony not inspected
|
||||
* since before FRUEHJAHR began is due from that day on. Never in WINTERRUHE.
|
||||
*/
|
||||
export function inspectionDueOn(
|
||||
input: { createdOn: string; lastInspectedOn: string | null },
|
||||
season: Pick<SeasonState, 'segment' | 'springSince'>,
|
||||
config: InspectionIntervalConfig,
|
||||
): InspectionDue | null {
|
||||
if (season.segment === 'WINTERRUHE') {
|
||||
return null;
|
||||
}
|
||||
const intervalDays = config.intervalDays[season.segment];
|
||||
const base = input.lastInspectedOn ?? input.createdOn;
|
||||
if (season.springSince !== null && base < season.springSince) {
|
||||
return { dueOn: season.springSince, firstOfYear: true, intervalDays };
|
||||
}
|
||||
return {
|
||||
dueOn: addDays(base, intervalDays),
|
||||
firstOfYear: false,
|
||||
intervalDays,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
// The age at which a queen counts as aged is SEASON_AUTOPILOT.queenMaxAgeYears,
|
||||
// judged by isQueenAged. No second constant may appear in src.
|
||||
const SRC = join(__dirname, '..');
|
||||
const THIS_FILE = 'season/queen-age-threshold.spec.ts';
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return sources(path);
|
||||
}
|
||||
return entry.name.endsWith('.ts') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function offenders(pattern: RegExp): string[] {
|
||||
return sources(SRC)
|
||||
.filter((file) => pattern.test(readFileSync(file, 'utf8')))
|
||||
.map((file) => relative(SRC, file).split(sep).join('/'))
|
||||
.filter((file) => file !== THIS_FILE)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe('queen age threshold', () => {
|
||||
it('there is no second queen age threshold in src', () =>
|
||||
expect(offenders(/OLD_QUEEN_AGE_YEARS|QUEEN_AGED_MIN_YEARS/)).toEqual([]));
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isQueenAged } from './queen-age';
|
||||
|
||||
describe('isQueenAged', () => {
|
||||
it('is aged from queenMaxAgeYears after the hatch year', () => {
|
||||
expect(isQueenAged(2024, '2026-06-10', 2)).toBe(true);
|
||||
expect(isQueenAged(2025, '2026-06-10', 2)).toBe(false);
|
||||
expect(isQueenAged(2023, '2026-06-10', 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('changes with the year, not within it', () => {
|
||||
expect(isQueenAged(2025, '2026-12-31', 2)).toBe(false);
|
||||
expect(isQueenAged(2025, '2027-01-01', 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('is never aged without a hatch year', () =>
|
||||
expect(isQueenAged(null, '2026-06-10', 1)).toBe(false));
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* An aged queen: the current year minus her hatch year reaches
|
||||
* `queenMaxAgeYears`. The one definition for QueenAged, the dashboard's
|
||||
* OLD_QUEEN and autopilot A8. `today` is a YYYY-MM-DD day.
|
||||
*/
|
||||
export function isQueenAged(
|
||||
hatchYear: number | null,
|
||||
today: string,
|
||||
queenMaxAgeYears: number,
|
||||
): boolean {
|
||||
if (hatchYear === null) {
|
||||
return false;
|
||||
}
|
||||
return Number(today.slice(0, 4)) - hatchYear >= queenMaxAgeYears;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||
import { calendarPhase, calendarSeasonState } from './season-calendar';
|
||||
|
||||
const FALLBACK = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
|
||||
|
||||
describe('calendarPhase', () => {
|
||||
it('is the WINTER phase of the previous year before SALWEIDE', () =>
|
||||
expect(calendarPhase('2026-03-10', FALLBACK)).toEqual({
|
||||
phase: 'WINTER',
|
||||
since: '2025-11-15',
|
||||
}));
|
||||
|
||||
it('is the latest phase whose calendar day has been reached', () => {
|
||||
expect(calendarPhase('2026-05-12', FALLBACK)).toEqual({
|
||||
phase: 'APFEL',
|
||||
since: '2026-04-28',
|
||||
});
|
||||
expect(calendarPhase('2026-04-28', FALLBACK)).toEqual({
|
||||
phase: 'APFEL',
|
||||
since: '2026-04-28',
|
||||
});
|
||||
expect(calendarPhase('2026-11-15', FALLBACK)).toEqual({
|
||||
phase: 'WINTER',
|
||||
since: '2026-11-15',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendarSeasonState', () => {
|
||||
const state = (today: string) =>
|
||||
calendarSeasonState('apiary-1', today, DEFAULT_PHENOLOGY_CONFIG);
|
||||
|
||||
it('2026-03-10 is WINTERRUHE since the WINTER date of 2025', () =>
|
||||
expect(state('2026-03-10')).toEqual({
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
phase: 'WINTER',
|
||||
phaseSource: 'CALENDAR',
|
||||
reportedOn: null,
|
||||
segment: 'WINTERRUHE',
|
||||
segmentSince: '2025-11-15',
|
||||
springSince: null,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
note: null,
|
||||
}));
|
||||
|
||||
it('2026-05-12 is SCHWARMZEIT since 2026-04-15 by calendar', () =>
|
||||
expect(state('2026-05-12')).toMatchObject({
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-15',
|
||||
springSince: '2026-03-20',
|
||||
}));
|
||||
|
||||
it('FRUEHJAHR starts with SALWEIDE and sets springSince', () =>
|
||||
expect(state('2026-04-01')).toMatchObject({
|
||||
segment: 'FRUEHJAHR',
|
||||
segmentSince: '2026-03-20',
|
||||
springSince: '2026-03-20',
|
||||
}));
|
||||
|
||||
it('WINTERRUHE after WINTER keeps springSince of the year', () =>
|
||||
expect(state('2026-12-01')).toMatchObject({
|
||||
segment: 'WINTERRUHE',
|
||||
segmentSince: '2026-11-15',
|
||||
springSince: '2026-03-20',
|
||||
}));
|
||||
|
||||
it('a segment spanning several phases begins at its first phase', () =>
|
||||
expect(state('2026-06-25')).toMatchObject({
|
||||
segment: 'SOMMER',
|
||||
segmentSince: '2026-06-20',
|
||||
}));
|
||||
|
||||
it('stays WINTERRUHE across the year change, springSince only after SALWEIDE', () => {
|
||||
expect(state('2026-12-31')).toMatchObject({
|
||||
year: 2026,
|
||||
segment: 'WINTERRUHE',
|
||||
segmentSince: '2026-11-15',
|
||||
springSince: '2026-03-20',
|
||||
});
|
||||
expect(state('2027-01-01')).toMatchObject({
|
||||
year: 2027,
|
||||
phase: 'WINTER',
|
||||
segment: 'WINTERRUHE',
|
||||
segmentSince: '2026-11-15',
|
||||
springSince: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { segmentOf } from './season-segment';
|
||||
import {
|
||||
PHENOLOGY_PHASES,
|
||||
type PhenologyPhaseKey,
|
||||
type SeasonState,
|
||||
} from './season-types';
|
||||
|
||||
type CalendarFallback = Record<PhenologyPhaseKey, string>;
|
||||
|
||||
interface PhaseOnDay {
|
||||
phase: PhenologyPhaseKey;
|
||||
day: string;
|
||||
}
|
||||
|
||||
/** The phases of `year` whose calendar day has been reached, in phase order. */
|
||||
function reachedPhases(
|
||||
year: number,
|
||||
today: string,
|
||||
fallback: CalendarFallback,
|
||||
): PhaseOnDay[] {
|
||||
return PHENOLOGY_PHASES.map((phase) => ({
|
||||
phase,
|
||||
day: `${year}-${fallback[phase]}`,
|
||||
})).filter(({ day }) => day <= today);
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase the calendar puts `today` in: the latest phase of the year whose
|
||||
* day has been reached; before SALWEIDE still the WINTER of the previous year.
|
||||
*/
|
||||
export function calendarPhase(
|
||||
today: string,
|
||||
fallback: CalendarFallback,
|
||||
): { phase: PhenologyPhaseKey | null; since: string | null } {
|
||||
const year = Number(today.slice(0, 4));
|
||||
const reached = reachedPhases(year, today, fallback);
|
||||
const latest = reached[reached.length - 1];
|
||||
if (latest === undefined) {
|
||||
return { phase: 'WINTER', since: `${year - 1}-${fallback.WINTER}` };
|
||||
}
|
||||
return { phase: latest.phase, since: latest.day };
|
||||
}
|
||||
|
||||
/** The day the segment of the latest reached phase began. */
|
||||
function segmentStart(
|
||||
reached: readonly PhaseOnDay[],
|
||||
swarmSeason: PhenologyConfig['swarmSeason'],
|
||||
fallbackStart: string,
|
||||
): string {
|
||||
const latest = reached[reached.length - 1];
|
||||
if (latest === undefined) {
|
||||
return fallbackStart;
|
||||
}
|
||||
const segment = segmentOf(latest.phase, swarmSeason);
|
||||
let since = latest.day;
|
||||
for (let i = reached.length - 2; i >= 0; i -= 1) {
|
||||
if (segmentOf(reached[i].phase, swarmSeason) !== segment) {
|
||||
break;
|
||||
}
|
||||
since = reached[i].day;
|
||||
}
|
||||
return since;
|
||||
}
|
||||
|
||||
/**
|
||||
* The season of an apiary from the calendar alone (no weather, no report):
|
||||
* what every apiary gets until the phenology data of Task 25 is there.
|
||||
*/
|
||||
export function calendarSeasonState(
|
||||
apiaryId: string,
|
||||
today: string,
|
||||
config: PhenologyConfig,
|
||||
): SeasonState {
|
||||
const year = Number(today.slice(0, 4));
|
||||
const { phase, since } = calendarPhase(today, config.calendarFallback);
|
||||
const reached = reachedPhases(year, today, config.calendarFallback);
|
||||
const springDay = `${year}-${config.calendarFallback.SALWEIDE}`;
|
||||
return {
|
||||
apiaryId,
|
||||
year,
|
||||
phase,
|
||||
phaseSource: 'CALENDAR',
|
||||
reportedOn: null,
|
||||
segment: segmentOf(phase, config.swarmSeason),
|
||||
segmentSince: segmentStart(reached, config.swarmSeason, since ?? today),
|
||||
springSince: springDay <= today ? springDay : null,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
note: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { segmentOf } from './season-segment';
|
||||
import type { PhenologyPhaseKey } from './season-types';
|
||||
|
||||
const DEFAULT_SWARM_SEASON = { from: 'KIRSCHE', until: 'LINDE' } as const;
|
||||
|
||||
describe('segmentOf', () => {
|
||||
it.each<[PhenologyPhaseKey | null, string]>([
|
||||
[null, 'WINTERRUHE'],
|
||||
['SALWEIDE', 'FRUEHJAHR'],
|
||||
['KIRSCHE', 'SCHWARMZEIT'],
|
||||
['APFEL', 'SCHWARMZEIT'],
|
||||
['HOLUNDER', 'SCHWARMZEIT'],
|
||||
['LINDE', 'SOMMER'],
|
||||
['TRACHTENDE', 'SPAETSOMMER'],
|
||||
['HERBST', 'HERBST'],
|
||||
['WINTER', 'WINTERRUHE'],
|
||||
])('%p -> %p', (phase, segment) => {
|
||||
expect(segmentOf(phase, DEFAULT_SWARM_SEASON)).toBe(segment);
|
||||
});
|
||||
|
||||
it('honours a configured swarm season start at APFEL', () =>
|
||||
expect(segmentOf('KIRSCHE', { from: 'APFEL', until: 'LINDE' })).toBe(
|
||||
'FRUEHJAHR',
|
||||
));
|
||||
|
||||
it('honours a configured swarm season end at HOLUNDER', () => {
|
||||
expect(segmentOf('APFEL', { from: 'KIRSCHE', until: 'HOLUNDER' })).toBe(
|
||||
'SCHWARMZEIT',
|
||||
);
|
||||
expect(segmentOf('HOLUNDER', { from: 'KIRSCHE', until: 'HOLUNDER' })).toBe(
|
||||
'SOMMER',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import {
|
||||
PHENOLOGY_PHASES,
|
||||
type PhenologyPhaseKey,
|
||||
type SeasonSegmentKey,
|
||||
} from './season-types';
|
||||
|
||||
const phaseIndex = (phase: PhenologyPhaseKey): number =>
|
||||
PHENOLOGY_PHASES.indexOf(phase);
|
||||
|
||||
/**
|
||||
* The season segment a phase belongs to: WINTERRUHE from WINTER (and before
|
||||
* the first phase), FRUEHJAHR from SALWEIDE, SCHWARMZEIT from the configured
|
||||
* start, SOMMER from its end, SPAETSOMMER from TRACHTENDE, HERBST from HERBST.
|
||||
*/
|
||||
export function segmentOf(
|
||||
phase: PhenologyPhaseKey | null,
|
||||
swarmSeason: PhenologyConfig['swarmSeason'],
|
||||
): SeasonSegmentKey {
|
||||
switch (phase) {
|
||||
case null:
|
||||
case 'WINTER':
|
||||
return 'WINTERRUHE';
|
||||
case 'HERBST':
|
||||
return 'HERBST';
|
||||
case 'TRACHTENDE':
|
||||
return 'SPAETSOMMER';
|
||||
default: {
|
||||
const index = phaseIndex(phase);
|
||||
if (index >= phaseIndex(swarmSeason.until)) {
|
||||
return 'SOMMER';
|
||||
}
|
||||
return index >= phaseIndex(swarmSeason.from)
|
||||
? 'SCHWARMZEIT'
|
||||
: 'FRUEHJAHR';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Season vocabulary shared by rule settings, the weather sources and the
|
||||
// season state. The Prisma enums follow in Task 23 with the same values.
|
||||
|
||||
export const PHENOLOGY_PHASES = [
|
||||
'SALWEIDE',
|
||||
'KIRSCHE',
|
||||
'APFEL',
|
||||
'HOLUNDER',
|
||||
'LINDE',
|
||||
'TRACHTENDE',
|
||||
'HERBST',
|
||||
'WINTER',
|
||||
] as const;
|
||||
|
||||
export const SEASON_SEGMENTS = [
|
||||
'WINTERRUHE',
|
||||
'FRUEHJAHR',
|
||||
'SCHWARMZEIT',
|
||||
'SOMMER',
|
||||
'SPAETSOMMER',
|
||||
'HERBST',
|
||||
] as const;
|
||||
|
||||
export const PHASE_SOURCES = ['REPORT', 'GTS', 'CALENDAR'] as const;
|
||||
|
||||
export type PhenologyPhaseKey = (typeof PHENOLOGY_PHASES)[number];
|
||||
export type SeasonSegmentKey = (typeof SEASON_SEGMENTS)[number];
|
||||
export type PhaseSourceKey = (typeof PHASE_SOURCES)[number];
|
||||
|
||||
export type SeasonNote =
|
||||
'NO_COORDINATES' | 'WEATHER_GAPS' | 'WEATHER_STALE' | null;
|
||||
|
||||
/** Daily mean temperature of one day (YYYY-MM-DD); null when unknown. */
|
||||
export interface DailyMean {
|
||||
day: string;
|
||||
meanTempC: number | null;
|
||||
}
|
||||
|
||||
export interface SeasonState {
|
||||
apiaryId: string;
|
||||
year: number;
|
||||
phase: PhenologyPhaseKey | null;
|
||||
phaseSource: PhaseSourceKey | null;
|
||||
reportedOn: string | null;
|
||||
segment: SeasonSegmentKey;
|
||||
segmentSince: string;
|
||||
springSince: string | null;
|
||||
gts: number | null;
|
||||
gtsThroughDay: string | null;
|
||||
note: SeasonNote;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeasonService } from './season.service';
|
||||
|
||||
@Module({
|
||||
providers: [SeasonService],
|
||||
exports: [SeasonService],
|
||||
})
|
||||
export class SeasonModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { SeasonService } from './season.service';
|
||||
|
||||
describe('SeasonService', () => {
|
||||
const findUnique = jest.fn();
|
||||
const db = { automationRule: { findUnique } } as unknown as DbClient;
|
||||
const service = new SeasonService();
|
||||
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset().mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
|
||||
const states = await service.statesFor(
|
||||
db,
|
||||
['apiary-1', 'apiary-2', 'apiary-1'],
|
||||
'2026-05-12',
|
||||
);
|
||||
|
||||
expect([...states.keys()]).toEqual(['apiary-1', 'apiary-2']);
|
||||
expect(states.get('apiary-2')).toMatchObject({
|
||||
apiaryId: 'apiary-2',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads no rule without apiaries', async () => {
|
||||
expect((await service.statesFor(db, [], '2026-05-12')).size).toBe(0);
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('follows the stored calendar of the PHENOLOGY rule', async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
id: 'rule-1',
|
||||
active: true,
|
||||
actionTemplate: null,
|
||||
config: {
|
||||
...DEFAULT_PHENOLOGY_CONFIG,
|
||||
calendarFallback: {
|
||||
...DEFAULT_PHENOLOGY_CONFIG.calendarFallback,
|
||||
SALWEIDE: '03-01',
|
||||
KIRSCHE: '04-01',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const states = await service.statesFor(db, ['apiary-1'], '2026-04-05');
|
||||
|
||||
expect(states.get('apiary-1')).toMatchObject({
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-01',
|
||||
springSince: '2026-03-01',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { calendarSeasonState } from './season-calendar';
|
||||
import type { SeasonState } from './season-types';
|
||||
|
||||
/**
|
||||
* The season of apiaries. Until the phenology data exists (Task 25) every
|
||||
* apiary gets the calendar state; then ApiaryPhenology takes over here, and
|
||||
* the callers stay as they are.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeasonService {
|
||||
/** The season state of each given apiary on `today` (YYYY-MM-DD, Berlin). */
|
||||
async statesFor(
|
||||
db: DbClient,
|
||||
apiaryIds: readonly string[],
|
||||
today: string,
|
||||
): Promise<Map<string, SeasonState>> {
|
||||
const states = new Map<string, SeasonState>();
|
||||
if (apiaryIds.length === 0) {
|
||||
return states;
|
||||
}
|
||||
const { config } = await readRule(db, 'PHENOLOGY');
|
||||
for (const apiaryId of new Set(apiaryIds)) {
|
||||
states.set(apiaryId, calendarSeasonState(apiaryId, today, config));
|
||||
}
|
||||
return states;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { DEFAULT_SEASON_AUTOPILOT_CONFIG } from '../automation/rule-config/season-autopilot.config';
|
||||
import { varroaMeasurementDue } from './varroa-due';
|
||||
|
||||
const CONFIG = DEFAULT_SEASON_AUTOPILOT_CONFIG;
|
||||
const SOMMER = { segment: 'SOMMER', springSince: '2026-03-20' } as const;
|
||||
const WINTERRUHE = {
|
||||
segment: 'WINTERRUHE',
|
||||
springSince: '2026-03-20',
|
||||
} as const;
|
||||
const NEVER_MEASURED = {
|
||||
createdOn: '2025-01-01',
|
||||
lastMeasuredOn: null,
|
||||
lastMeasurementId: null,
|
||||
};
|
||||
|
||||
describe('varroaMeasurementDue', () => {
|
||||
it('is due 28 days after the last measurement outside WINTERRUHE', () =>
|
||||
expect(
|
||||
varroaMeasurementDue(
|
||||
{
|
||||
createdOn: '2025-01-01',
|
||||
lastMeasuredOn: '2026-06-01',
|
||||
lastMeasurementId: 'm-1',
|
||||
},
|
||||
SOMMER,
|
||||
CONFIG,
|
||||
'2026-06-30',
|
||||
),
|
||||
).toEqual({ dueOn: '2026-06-29', kind: 'INTERVAL', anchor: 'm-1' }));
|
||||
|
||||
it('counts from createdOn without a measurement and anchors on none', () =>
|
||||
expect(
|
||||
varroaMeasurementDue(
|
||||
{ ...NEVER_MEASURED, createdOn: '2026-04-20' },
|
||||
SOMMER,
|
||||
CONFIG,
|
||||
'2026-06-30',
|
||||
),
|
||||
).toEqual({ dueOn: '2026-05-18', kind: 'INTERVAL', anchor: 'none' }));
|
||||
|
||||
it('uses the configured interval', () =>
|
||||
expect(
|
||||
varroaMeasurementDue(
|
||||
{ ...NEVER_MEASURED, createdOn: '2026-04-20' },
|
||||
SOMMER,
|
||||
{ ...CONFIG, varroaMeasureIntervalDays: 14 },
|
||||
'2026-06-30',
|
||||
),
|
||||
).toMatchObject({ dueOn: '2026-05-04' }));
|
||||
|
||||
it('is due on 11-15 in the winter window without a measurement since 11-01', () =>
|
||||
expect(
|
||||
varroaMeasurementDue(
|
||||
{
|
||||
createdOn: '2025-01-01',
|
||||
lastMeasuredOn: '2026-09-20',
|
||||
lastMeasurementId: 'm-1',
|
||||
},
|
||||
WINTERRUHE,
|
||||
CONFIG,
|
||||
'2026-11-20',
|
||||
),
|
||||
).toEqual({
|
||||
dueOn: '2026-11-15',
|
||||
kind: 'WINTER_WINDOW',
|
||||
anchor: 'winter-2026',
|
||||
}));
|
||||
|
||||
it('is not due in the winter window after a measurement since 11-01', () =>
|
||||
expect(
|
||||
varroaMeasurementDue(
|
||||
{
|
||||
createdOn: '2025-01-01',
|
||||
lastMeasuredOn: '2026-11-03',
|
||||
lastMeasurementId: 'm-2',
|
||||
},
|
||||
WINTERRUHE,
|
||||
CONFIG,
|
||||
'2026-11-20',
|
||||
),
|
||||
).toBeNull());
|
||||
|
||||
it('is not due in WINTERRUHE outside the winter window', () => {
|
||||
expect(
|
||||
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-12-21'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-02-10'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the winter window on its first and last day', () => {
|
||||
expect(
|
||||
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-11-15'),
|
||||
).toMatchObject({ kind: 'WINTER_WINDOW' });
|
||||
expect(
|
||||
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-12-20'),
|
||||
).toMatchObject({ kind: 'WINTER_WINDOW' });
|
||||
});
|
||||
|
||||
it('moves an interval due day inside the winter to the start of FRUEHJAHR', () =>
|
||||
expect(
|
||||
varroaMeasurementDue(
|
||||
{
|
||||
createdOn: '2025-01-01',
|
||||
lastMeasuredOn: '2026-11-20',
|
||||
lastMeasurementId: 'm-3',
|
||||
},
|
||||
{ segment: 'FRUEHJAHR', springSince: '2027-03-20' },
|
||||
CONFIG,
|
||||
'2027-03-25',
|
||||
),
|
||||
).toEqual({ dueOn: '2027-03-20', kind: 'INTERVAL', anchor: 'm-3' }));
|
||||
|
||||
it('keeps a due day after the start of FRUEHJAHR', () =>
|
||||
expect(
|
||||
varroaMeasurementDue(
|
||||
{
|
||||
createdOn: '2025-01-01',
|
||||
lastMeasuredOn: '2027-03-10',
|
||||
lastMeasurementId: 'm-4',
|
||||
},
|
||||
{ segment: 'FRUEHJAHR', springSince: '2027-03-20' },
|
||||
CONFIG,
|
||||
'2027-04-01',
|
||||
),
|
||||
).toMatchObject({ dueOn: '2027-04-07' }));
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { addDays } from '../common/dates';
|
||||
import type { SeasonAutopilotConfig } from '../automation/rule-config/rule-config.types';
|
||||
import type { SeasonState } from './season-types';
|
||||
|
||||
export interface VarroaDue {
|
||||
dueOn: string;
|
||||
kind: 'INTERVAL' | 'WINTER_WINDOW';
|
||||
/** Changes with every new measurement (or winter), so one due date stays one reminder. */
|
||||
anchor: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Invariant: the winter window (SEASON_AUTOPILOT.winterVarroaWindow, default
|
||||
* 11-15) lies inside WINTERRUHE, i.e. it does not begin before the WINTER
|
||||
* date of PHENOLOGY.calendarFallback (default 11-15). Before WINTER the
|
||||
* interval rule applies instead.
|
||||
*/
|
||||
|
||||
/** A measurement from this day on counts for the winter window of the year. */
|
||||
const WINTER_MEASUREMENT_FROM = '11-01';
|
||||
|
||||
/**
|
||||
* The day a varroa measurement is due. Outside WINTERRUHE: the last
|
||||
* measurement (else the creation of the colony) plus the interval. In
|
||||
* WINTERRUHE only inside the winter window, once, if there was no
|
||||
* measurement since 11-01.
|
||||
*/
|
||||
export function varroaMeasurementDue(
|
||||
input: {
|
||||
createdOn: string;
|
||||
lastMeasuredOn: string | null;
|
||||
lastMeasurementId: string | null;
|
||||
},
|
||||
season: Pick<SeasonState, 'segment' | 'springSince'>,
|
||||
config: SeasonAutopilotConfig,
|
||||
today: string,
|
||||
): VarroaDue | null {
|
||||
if (season.segment !== 'WINTERRUHE') {
|
||||
const base = input.lastMeasuredOn ?? input.createdOn;
|
||||
const intervalDue = addDays(base, config.varroaMeasureIntervalDays);
|
||||
// A due day that fell into the winter is taken up at the start of
|
||||
// FRUEHJAHR, like the first inspection of the year.
|
||||
const dueOn =
|
||||
season.springSince !== null && intervalDue < season.springSince
|
||||
? season.springSince
|
||||
: intervalDue;
|
||||
return {
|
||||
dueOn,
|
||||
kind: 'INTERVAL',
|
||||
anchor: input.lastMeasurementId ?? 'none',
|
||||
};
|
||||
}
|
||||
const year = today.slice(0, 4);
|
||||
const { from, to } = config.winterVarroaWindow;
|
||||
const inWindow = today >= `${year}-${from}` && today <= `${year}-${to}`;
|
||||
const measuredThisWinter =
|
||||
input.lastMeasuredOn !== null &&
|
||||
input.lastMeasuredOn >= `${year}-${WINTER_MEASUREMENT_FROM}`;
|
||||
if (!inWindow || measuredThisWinter) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
dueOn: `${year}-${from}`,
|
||||
kind: 'WINTER_WINDOW',
|
||||
anchor: `winter-${year}`,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user