feat(autopilot): /autopilot/today liefert Saison-Zusätze für das Bienenjahr-Rad (T5b B5)

- seasons[] bekommt segmentSince, reportedOn und note
  (NO_COORDINATES | WEATHER_GAPS | WEATHER_STALE).
- Neues seasonCalendar: calendarFallback (8 Phasen, MM-DD), swarmSeason und
  winterVarroaWindow aus den Regel-Configs, auch bei inaktiver Regel.
- Nur Zusätze, keine Migration, Recht bleibt colonies:read;
  SEASON_AUTOPILOT wird je Anfrage einmal gelesen.

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 15:55:24 +02:00
1 parent 90d1c5244f
commit bcc7cbfd13
4 files changed
+260 -11

No files matched your search

@@ -84,6 +84,56 @@ class SeasonOfApiaryDto {
phase!: AutopilotToday['seasons'][number]['phase'];
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
phaseSource!: AutopilotToday['seasons'][number]['phaseSource'];
@ApiProperty({ description: 'Day the current segment began (YYYY-MM-DD).' })
segmentSince!: string;
@ApiProperty({
nullable: true,
type: String,
format: 'date',
description:
'Day of the phase report the phase rests on; null without one.',
})
reportedOn!: string | null;
@ApiProperty({
enum: ['NO_COORDINATES', 'WEATHER_GAPS', 'WEATHER_STALE'],
nullable: true,
})
note!: AutopilotToday['seasons'][number]['note'];
}
class SwarmSeasonDto {
@ApiProperty({ enum: PHENOLOGY_PHASES })
from!: AutopilotToday['seasonCalendar']['swarmSeason']['from'];
@ApiProperty({ enum: PHENOLOGY_PHASES })
until!: AutopilotToday['seasonCalendar']['swarmSeason']['until'];
}
class MonthDayWindowDto {
@ApiProperty({ example: '11-15' }) from!: string;
@ApiProperty({ example: '12-20' }) to!: string;
}
/** Calendar day ('MM-DD') of each phenology phase. */
class CalendarFallbackDto implements Record<
(typeof PHENOLOGY_PHASES)[number],
string
> {
@ApiProperty({ example: '03-20' }) SALWEIDE!: string;
@ApiProperty({ example: '04-15' }) KIRSCHE!: string;
@ApiProperty({ example: '04-28' }) APFEL!: string;
@ApiProperty({ example: '05-30' }) HOLUNDER!: string;
@ApiProperty({ example: '06-20' }) LINDE!: string;
@ApiProperty({ example: '08-01' }) TRACHTENDE!: string;
@ApiProperty({ example: '09-15' }) HERBST!: string;
@ApiProperty({ example: '11-15' }) WINTER!: string;
}
class SeasonCalendarDto {
@ApiProperty({ type: CalendarFallbackDto })
calendarFallback!: CalendarFallbackDto;
@ApiProperty({ type: SwarmSeasonDto }) swarmSeason!: SwarmSeasonDto;
@ApiProperty({ type: MonthDayWindowDto })
winterVarroaWindow!: MonthDayWindowDto;
}
export class AutopilotTodayDto {
@@ -96,5 +146,7 @@ export class AutopilotTodayDto {
inspectionDue!: InspectionDueDto[];
@ApiProperty() suggestionsOpen!: number;
@ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[];
@ApiProperty({ type: SeasonCalendarDto })
seasonCalendar!: SeasonCalendarDto;
@ApiProperty() tasksVisible!: boolean;
}
@@ -352,10 +352,102 @@ describe('NextStepsService', () => {
segment: 'SCHWARMZEIT',
phase: 'APFEL',
phaseSource: 'CALENDAR',
segmentSince: '2026-04-15',
reportedOn: null,
note: null,
},
]);
});
it('today() maps reportedOn and note from the season state', async () => {
grant('colonies:read');
prisma.apiary.findMany.mockResolvedValue([
{ id: 'a1', name: 'Heimstand' },
]);
const seasons = new SeasonService();
jest.spyOn(seasons, 'statesFor').mockResolvedValue(
new Map([
[
'a1',
{
apiaryId: 'a1',
year: 2026,
phase: 'KIRSCHE',
phaseSource: 'REPORT',
reportedOn: '2026-04-05',
segment: 'SCHWARMZEIT',
segmentSince: '2026-04-05',
springSince: '2026-03-18',
gts: null,
gtsThroughDay: null,
note: 'WEATHER_STALE',
},
],
]),
);
service = new NextStepsService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
seasons,
new SuggestionViewsService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
),
);
const today = await service.today('u1', TODAY);
expect(today.seasons).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Heimstand',
segment: 'SCHWARMZEIT',
phase: 'KIRSCHE',
phaseSource: 'REPORT',
segmentSince: '2026-04-05',
reportedOn: '2026-04-05',
note: 'WEATHER_STALE',
},
]);
});
it('carries the season calendar of the PHENOLOGY and SEASON_AUTOPILOT rules', async () => {
grant('colonies:read');
prisma.automationRule.findUnique.mockImplementation(
(args: { where: { triggerType: string } }) =>
Promise.resolve(
args.where.triggerType === 'PHENOLOGY'
? {
id: 'r1',
active: true,
config: {
calendarFallback: { KIRSCHE: '04-10' },
swarmSeason: { from: 'APFEL', until: 'LINDE' },
},
actionTemplate: null,
}
: null,
),
);
const today = await service.today('u1', TODAY);
expect(today.seasonCalendar).toEqual({
calendarFallback: {
SALWEIDE: '03-20',
KIRSCHE: '04-10',
APFEL: '04-28',
HOLUNDER: '05-30',
LINDE: '06-20',
TRACHTENDE: '08-01',
HERBST: '09-15',
WINTER: '11-15',
},
swarmSeason: { from: 'APFEL', until: 'LINDE' },
winterVarroaWindow: { from: '11-15', to: '12-20' },
});
});
it('has no next step and counts no suggestions without tasks:read', async () => {
grant('colonies:read');
@@ -20,7 +20,15 @@ import {
inspectionDueOn,
inspectionIntervalOf,
} from '../season/inspection-due';
import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
import type {
SeasonNote,
SeasonSegmentKey,
SeasonState,
} from '../season/season-types';
import type {
PhenologyConfig,
SeasonAutopilotConfig,
} from '../automation/rule-config/rule-config.types';
import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
import { renderSuggestion } from '../suggestions/suggestion-texts';
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
@@ -62,7 +70,16 @@ export interface AutopilotToday {
segment: SeasonSegmentKey;
phase: ColonyNextSteps['phase'];
phaseSource: ColonyNextSteps['phaseSource'];
segmentSince: string;
reportedOn: string | null;
note: SeasonNote;
}[];
/** The calendar the season runs on (rule settings), for the year wheel. */
seasonCalendar: {
calendarFallback: PhenologyConfig['calendarFallback'];
swarmSeason: PhenologyConfig['swarmSeason'];
winterVarroaWindow: SeasonAutopilotConfig['winterVarroaWindow'];
};
tasksVisible: boolean;
}
@@ -86,6 +103,7 @@ interface Computation {
intervalDays: Readonly<
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>
>;
winterVarroaWindow: SeasonAutopilotConfig['winterVarroaWindow'];
}
/** A withdrawal period longer than this is not looked for. */
@@ -211,15 +229,21 @@ export class NextStepsService {
where: LIVING_COLONY_WHERE,
select: { id: true },
});
const { colonies, seasons, intervalDays } = await this.compute(
living.map((colony) => colony.id),
held,
today,
);
const apiaries = await this.prisma.apiary.findMany({
where: { id: { in: [...seasons.keys()] } },
select: { id: true, name: true },
});
const { colonies, seasons, intervalDays, winterVarroaWindow } =
await this.compute(
living.map((colony) => colony.id),
held,
today,
);
// SeasonService reads PHENOLOGY internally (and not at all without
// apiaries), so the calendar needs its own read.
const [apiaries, phenology] = await Promise.all([
this.prisma.apiary.findMany({
where: { id: { in: [...seasons.keys()] } },
select: { id: true, name: true },
}),
readRule(this.prisma, 'PHENOLOGY'),
]);
const apiaryName = new Map(apiaries.map((a) => [a.id, a.name] as const));
const nameOf = (apiaryId: string): string => apiaryName.get(apiaryId) ?? '';
const suggestionsOpen = held.has('tasks:read')
@@ -250,7 +274,15 @@ export class NextStepsService {
segment: season.segment,
phase: season.phase,
phaseSource: season.phaseSource,
segmentSince: season.segmentSince,
reportedOn: season.reportedOn,
note: season.note,
})),
seasonCalendar: {
calendarFallback: phenology.config.calendarFallback,
swarmSeason: phenology.config.swarmSeason,
winterVarroaWindow,
},
tasksVisible: held.has('tasks:read'),
};
}
@@ -410,6 +442,7 @@ export class NextStepsService {
colonies,
seasons: states,
intervalDays: interval.config.intervalDays,
winterVarroaWindow: autopilot.config.winterVarroaWindow,
};
}