diff --git a/ApiCore-Backend/src/autopilot/dto/next-steps-response.dto.ts b/ApiCore-Backend/src/autopilot/dto/next-steps-response.dto.ts index 56606c2..8759b6f 100644 --- a/ApiCore-Backend/src/autopilot/dto/next-steps-response.dto.ts +++ b/ApiCore-Backend/src/autopilot/dto/next-steps-response.dto.ts @@ -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; } diff --git a/ApiCore-Backend/src/autopilot/next-steps.service.spec.ts b/ApiCore-Backend/src/autopilot/next-steps.service.spec.ts index 478de7a..b592db5 100644 --- a/ApiCore-Backend/src/autopilot/next-steps.service.spec.ts +++ b/ApiCore-Backend/src/autopilot/next-steps.service.spec.ts @@ -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'); diff --git a/ApiCore-Backend/src/autopilot/next-steps.service.ts b/ApiCore-Backend/src/autopilot/next-steps.service.ts index 1d80dd5..6eab889 100644 --- a/ApiCore-Backend/src/autopilot/next-steps.service.ts +++ b/ApiCore-Backend/src/autopilot/next-steps.service.ts @@ -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, 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, }; } diff --git a/ApiCore-Backend/test/next-steps.e2e-spec.ts b/ApiCore-Backend/test/next-steps.e2e-spec.ts index aec717c..dc560bd 100644 --- a/ApiCore-Backend/test/next-steps.e2e-spec.ts +++ b/ApiCore-Backend/test/next-steps.e2e-spec.ts @@ -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; + 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, + 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/); + }); });