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,
};
}
+73 -1
View File
@@ -38,7 +38,18 @@ interface TodayBody {
nextStep: { colonyId: string } | null;
inspectionDue: { apiaryId: string }[];
suggestionsOpen: number;
seasons: { apiaryId: string; segment: string }[];
seasons: {
apiaryId: string;
segment: string;
segmentSince: string;
reportedOn: string | null;
note: string | null;
}[];
seasonCalendar?: {
calendarFallback: Record<string, string>;
swarmSeason: { from: string; until: string };
winterVarroaWindow: { from: string; to: string };
};
tasksVisible: boolean;
}
@@ -454,4 +465,65 @@ describe('Next steps (e2e)', () => {
expect(body.suggestionsOpen).toBeGreaterThanOrEqual(4);
expect(body.nextStep).not.toBeNull();
});
it('GET /autopilot/today carries seasonCalendar and segmentSince per apiary', async () => {
const example = await nextSteps.today(users.full.id, EXAMPLE_DAY);
expect(example.seasons).toContainEqual({
apiaryId,
apiaryName: `Next steps ${suffix}`,
segment: 'SCHWARMZEIT',
phase: 'APFEL',
phaseSource: 'CALENDAR',
segmentSince: '2026-04-15',
reportedOn: null,
note: null,
});
const response = await get(users.noTasks.token, '/autopilot/today').expect(
200,
);
const body = response.body as TodayBody;
const own = body.seasons.find((season) => season.apiaryId === apiaryId);
expect(own?.segmentSince).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(own).toHaveProperty('reportedOn');
expect(own).toHaveProperty('note');
expect(body.seasonCalendar).toEqual({
calendarFallback: expect.objectContaining({
SALWEIDE: expect.stringMatching(/^\d{2}-\d{2}$/) as string,
WINTER: expect.stringMatching(/^\d{2}-\d{2}$/) as string,
}) as Record<string, string>,
swarmSeason: {
from: expect.any(String) as string,
until: expect.any(String) as string,
},
winterVarroaWindow: {
from: expect.stringMatching(/^\d{2}-\d{2}$/) as string,
to: expect.stringMatching(/^\d{2}-\d{2}$/) as string,
},
});
expect(Object.keys(body.seasonCalendar?.calendarFallback ?? {})).toEqual([
'SALWEIDE',
'KIRSCHE',
'APFEL',
'HOLUNDER',
'LINDE',
'TRACHTENDE',
'HERBST',
'WINTER',
]);
});
// A changed PHENOLOGY config is covered by the unit test: mutating the shared
// rule row here would race the other e2e suites that read it in parallel.
it('a user without colonies:read still gets 403 and no calendar', async () => {
const noColonies = await createUser('nocalendar', ['tasks:read']);
const response = await get(noColonies.token, '/autopilot/today').expect(
403,
);
expect(response.body).not.toHaveProperty('seasonCalendar');
expect(JSON.stringify(response.body)).not.toMatch(/calendarFallback/);
});
});