feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -1,7 +1,6 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { berlinDayOf, dayToDate } from '../common/dates';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import type { AutopilotInput } from './autopilot.types';
|
||||
|
||||
/**
|
||||
@@ -72,8 +71,8 @@ export async function loadAutopilotInputIn(
|
||||
const lastTreatment = treatments
|
||||
.map((treatment) => ({
|
||||
id: treatment.id,
|
||||
startDay: utcDay(treatment.startDate ?? treatment.date),
|
||||
endDay: utcDay(treatment.endDate ?? treatment.date),
|
||||
startDay: berlinDayOf(treatment.startDate ?? treatment.date),
|
||||
endDay: berlinDayOf(treatment.endDate ?? treatment.date),
|
||||
}))
|
||||
.reduce<{ id: string; startDay: string; endDay: string } | null>(
|
||||
(latest, candidate) =>
|
||||
@@ -124,9 +123,11 @@ export async function loadAutopilotInputIn(
|
||||
lastInspection:
|
||||
inspection === null
|
||||
? null
|
||||
: { ...inspection, date: utcDay(inspection.date) },
|
||||
: { ...inspection, date: berlinDayOf(inspection.date) },
|
||||
lastVarroa:
|
||||
varroa === null ? null : { id: varroa.id, date: utcDay(varroa.date) },
|
||||
varroa === null
|
||||
? null
|
||||
: { id: varroa.id, date: berlinDayOf(varroa.date) },
|
||||
lastTreatment,
|
||||
varroaSinceLastTreatmentEnd,
|
||||
feedingsSinceInspection,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { AutopilotTodayDto } from './dto/next-steps-response.dto';
|
||||
import { NextStepsService, type AutopilotToday } from './next-steps.service';
|
||||
|
||||
@ApiTags('autopilot')
|
||||
@ApiBearerAuth()
|
||||
@Controller('autopilot')
|
||||
export class AutopilotTodayController {
|
||||
constructor(private readonly nextSteps: NextStepsService) {}
|
||||
|
||||
/**
|
||||
* "Today": the most urgent step over all colonies, the due inspections
|
||||
* per apiary, the number of open suggestions and the season per apiary.
|
||||
* Tasks and suggestions only with `tasks:read`.
|
||||
*/
|
||||
@ApiOkResponse({ type: AutopilotTodayDto })
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('today')
|
||||
today(@CurrentUser() actor: AuthenticatedUser): Promise<AutopilotToday> {
|
||||
return this.nextSteps.today(actor.id);
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,34 @@ import { Module } from '@nestjs/common';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { AutopilotEventHandlers } from './autopilot.handlers';
|
||||
import { AutopilotTodayController } from './autopilot-today.controller';
|
||||
import { AutopilotService } from './autopilot.service';
|
||||
import { AutopilotStep } from './autopilot.step';
|
||||
import { ColonyNextStepsController } from './colony-next-steps.controller';
|
||||
import { NextStepsService } from './next-steps.service';
|
||||
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
|
||||
|
||||
/**
|
||||
* The season autopilot: the event handler and the daily steps that keep the
|
||||
* autopilot suggestions of each colony in line with the rules. Imported by
|
||||
* AppModule and WorkerModule (the handler) and by JobsModule (the steps).
|
||||
* autopilot suggestions of each colony in line with the rules, and the read
|
||||
* side "next steps" / "today". Imported by AppModule and WorkerModule (the
|
||||
* handler), JobsModule (the steps) and ApiariesModule (the colony overview).
|
||||
*/
|
||||
@Module({
|
||||
imports: [SeasonModule, SuggestionsModule],
|
||||
controllers: [ColonyNextStepsController, AutopilotTodayController],
|
||||
providers: [
|
||||
AutopilotService,
|
||||
NextStepsService,
|
||||
AutopilotEventHandlers,
|
||||
AutopilotStep,
|
||||
SuggestionsExpiryStep,
|
||||
],
|
||||
exports: [AutopilotService, AutopilotStep, SuggestionsExpiryStep],
|
||||
exports: [
|
||||
AutopilotService,
|
||||
NextStepsService,
|
||||
AutopilotStep,
|
||||
SuggestionsExpiryStep,
|
||||
],
|
||||
})
|
||||
export class AutopilotModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { ColonyNextStepsDto } from './dto/next-steps-response.dto';
|
||||
import type { ColonyNextSteps } from './next-step-ranking';
|
||||
import { NextStepsService } from './next-steps.service';
|
||||
|
||||
@ApiTags('colonies')
|
||||
@ApiBearerAuth()
|
||||
@Controller('colonies')
|
||||
export class ColonyNextStepsController {
|
||||
constructor(private readonly nextSteps: NextStepsService) {}
|
||||
|
||||
/** The next steps of one colony, only what the caller may see. */
|
||||
@ApiOkResponse({ type: ColonyNextStepsDto })
|
||||
@RequirePermission('colonies:read')
|
||||
@Get(':id/next-steps')
|
||||
nextStepsOf(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<ColonyNextSteps> {
|
||||
return this.nextSteps.forColony(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { SuggestionIntent } from '../../../generated/prisma';
|
||||
import {
|
||||
PHASE_SOURCES,
|
||||
PHENOLOGY_PHASES,
|
||||
SEASON_SEGMENTS,
|
||||
} from '../../season/season-types';
|
||||
import type { ColonyNextSteps, NextStep } from '../next-step-ranking';
|
||||
import type { AutopilotToday } from '../next-steps.service';
|
||||
|
||||
const FORMS = [
|
||||
'inspection',
|
||||
'varroa',
|
||||
'feeding',
|
||||
'treatment',
|
||||
'winter-check',
|
||||
] as const;
|
||||
|
||||
class NextStepActionDto {
|
||||
@ApiProperty({ enum: ['CAPTURE', 'ACCEPT', 'CLEANUP', 'OPEN_TASK'] })
|
||||
type!: NextStep['action']['type'];
|
||||
@ApiProperty({ enum: FORMS, required: false }) form?: (typeof FORMS)[number];
|
||||
@ApiProperty({ required: false }) href?: string;
|
||||
@ApiProperty({ required: false }) suggestionId?: string;
|
||||
@ApiProperty({ required: false }) taskId?: string;
|
||||
}
|
||||
|
||||
export class NextStepDto {
|
||||
@ApiProperty() key!: string;
|
||||
@ApiProperty({ enum: ['TASK', 'SUGGESTION'] }) kind!: NextStep['kind'];
|
||||
@ApiProperty({ enum: SuggestionIntent, nullable: true })
|
||||
intent!: SuggestionIntent | null;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: [String] }) reasons!: string[];
|
||||
@ApiProperty({ nullable: true, type: String }) dueOn!: string | null;
|
||||
@ApiProperty({ enum: ['OVERDUE', 'TODAY', 'SOON', 'LATER', 'NONE'] })
|
||||
urgency!: NextStep['urgency'];
|
||||
@ApiProperty({ type: [String] }) suggestionIds!: string[];
|
||||
@ApiProperty({ type: NextStepActionDto }) action!: NextStepActionDto;
|
||||
@ApiProperty() dismissible!: boolean;
|
||||
}
|
||||
|
||||
export class ColonyNextStepsDto {
|
||||
@ApiProperty() colonyId!: string;
|
||||
@ApiProperty({ enum: SEASON_SEGMENTS }) segment!: ColonyNextSteps['segment'];
|
||||
@ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true })
|
||||
phase!: ColonyNextSteps['phase'];
|
||||
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
|
||||
phaseSource!: ColonyNextSteps['phaseSource'];
|
||||
@ApiProperty({ nullable: true, type: String }) inspectionDueOn!:
|
||||
string | null;
|
||||
@ApiProperty({ nullable: true, type: String }) withdrawalUntil!:
|
||||
string | null;
|
||||
@ApiProperty({ type: [NextStepDto] }) steps!: NextStepDto[];
|
||||
}
|
||||
|
||||
class TodayNextStepDto extends NextStepDto {
|
||||
@ApiProperty() colonyId!: string;
|
||||
@ApiProperty() colonyCode!: string;
|
||||
}
|
||||
|
||||
class DueColonyDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty() code!: string;
|
||||
@ApiProperty() dueOn!: string;
|
||||
@ApiProperty() overdueDays!: number;
|
||||
}
|
||||
|
||||
class InspectionDueDto {
|
||||
@ApiProperty() apiaryId!: string;
|
||||
@ApiProperty() apiaryName!: string;
|
||||
@ApiProperty({ enum: SEASON_SEGMENTS })
|
||||
segment!: AutopilotToday['seasons'][number]['segment'];
|
||||
@ApiProperty({ nullable: true, type: Number }) intervalDays!: number | null;
|
||||
@ApiProperty({ type: [DueColonyDto] }) colonies!: DueColonyDto[];
|
||||
}
|
||||
|
||||
class SeasonOfApiaryDto {
|
||||
@ApiProperty() apiaryId!: string;
|
||||
@ApiProperty() apiaryName!: string;
|
||||
@ApiProperty({ enum: SEASON_SEGMENTS })
|
||||
segment!: AutopilotToday['seasons'][number]['segment'];
|
||||
@ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true })
|
||||
phase!: AutopilotToday['seasons'][number]['phase'];
|
||||
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
|
||||
phaseSource!: AutopilotToday['seasons'][number]['phaseSource'];
|
||||
}
|
||||
|
||||
export class AutopilotTodayDto {
|
||||
@ApiProperty({ type: TodayNextStepDto, nullable: true })
|
||||
nextStep!: TodayNextStepDto | null;
|
||||
@ApiProperty({
|
||||
type: [InspectionDueDto],
|
||||
description: 'Colonies whose inspection is due today or overdue.',
|
||||
})
|
||||
inspectionDue!: InspectionDueDto[];
|
||||
@ApiProperty() suggestionsOpen!: number;
|
||||
@ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[];
|
||||
@ApiProperty() tasksVisible!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import type { SuggestionIntent } from '../../generated/prisma';
|
||||
import {
|
||||
FORM_OF_INTENT,
|
||||
INTENT_ORDER,
|
||||
mergeSameForm,
|
||||
rankSteps,
|
||||
topSteps,
|
||||
toNextStep,
|
||||
type StepSource,
|
||||
} from './next-step-ranking';
|
||||
|
||||
const TODAY = '2026-05-12';
|
||||
|
||||
let counter = 0;
|
||||
function source(overrides: Partial<StepSource> = {}): StepSource {
|
||||
counter += 1;
|
||||
const id = overrides.id ?? `s${String(counter).padStart(3, '0')}`;
|
||||
return {
|
||||
kind: 'SUGGESTION',
|
||||
id,
|
||||
intent: 'INSPECT',
|
||||
mode: 'DO',
|
||||
status: 'OPEN',
|
||||
priority: 'NORMAL',
|
||||
dueOn: TODAY,
|
||||
createdAt: new Date('2026-05-01T08:00:00Z'),
|
||||
title: 'Durchsicht',
|
||||
reasons: ['Grund.'],
|
||||
colonyId: 'col-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (items: readonly StepSource[]): string[] => items.map((i) => i.id);
|
||||
|
||||
describe('rankSteps', () => {
|
||||
it('IN_PROGRESS comes first, then overdue, then by priority and the intent table', () => {
|
||||
const inProgress = source({
|
||||
id: 'a-progress',
|
||||
kind: 'TASK',
|
||||
mode: null,
|
||||
intent: null,
|
||||
status: 'IN_PROGRESS',
|
||||
priority: 'URGENT',
|
||||
dueOn: '2026-06-30',
|
||||
});
|
||||
const overdueLow = source({
|
||||
id: 'b-overdue-low',
|
||||
priority: 'LOW',
|
||||
dueOn: '2026-05-01',
|
||||
});
|
||||
const todayHigh = source({
|
||||
id: 'c-today-high',
|
||||
priority: 'HIGH',
|
||||
intent: 'FEED',
|
||||
});
|
||||
const todayHighVarroa = source({
|
||||
id: 'd-today-high-varroa',
|
||||
priority: 'HIGH',
|
||||
intent: 'VARROA_TREAT',
|
||||
});
|
||||
const soon = source({ id: 'e-soon', dueOn: '2026-05-14' });
|
||||
const later = source({ id: 'f-later', dueOn: '2026-05-30' });
|
||||
const dateless = source({ id: 'g-none', dueOn: null });
|
||||
|
||||
const ranked = rankSteps(
|
||||
[
|
||||
dateless,
|
||||
later,
|
||||
soon,
|
||||
todayHigh,
|
||||
overdueLow,
|
||||
todayHighVarroa,
|
||||
inProgress,
|
||||
],
|
||||
TODAY,
|
||||
);
|
||||
|
||||
// Urgency 0 holds the in-progress task, today and overdue steps; inside
|
||||
// it priority decides, then the intent table (varroa before feed).
|
||||
expect(ids(ranked)).toEqual([
|
||||
'a-progress',
|
||||
'd-today-high-varroa',
|
||||
'c-today-high',
|
||||
'b-overdue-low',
|
||||
'e-soon',
|
||||
'f-later',
|
||||
'g-none',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a dateless URGENT step counts as urgent', () => {
|
||||
const urgentNoDate = source({
|
||||
id: 'urgent',
|
||||
dueOn: null,
|
||||
priority: 'URGENT',
|
||||
});
|
||||
const soon = source({ id: 'soon', dueOn: '2026-05-14', priority: 'HIGH' });
|
||||
const normalDateless = source({ id: 'plain', dueOn: null });
|
||||
|
||||
expect(ids(rankSteps([normalDateless, soon, urgentNoDate], TODAY))).toEqual(
|
||||
['urgent', 'soon', 'plain'],
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a task without an intent like FOLLOW_UP', () => {
|
||||
const task = source({ id: 'task', kind: 'TASK', mode: null, intent: null });
|
||||
const cleanup = source({ id: 'cleanup', intent: 'CLEANUP', mode: 'DO' });
|
||||
const varroa = source({ id: 'varroa', intent: 'VARROA_TREAT' });
|
||||
|
||||
expect(ids(rankSteps([cleanup, task, varroa], TODAY))).toEqual([
|
||||
'varroa',
|
||||
'task',
|
||||
'cleanup',
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders ties by due day, creation time and id', () => {
|
||||
const early = source({ id: 'z', dueOn: '2026-05-10' });
|
||||
const sameDayOlder = source({
|
||||
id: 'y',
|
||||
dueOn: '2026-05-11',
|
||||
createdAt: new Date('2026-05-01T00:00:00Z'),
|
||||
});
|
||||
const sameDayNewer = source({
|
||||
id: 'x',
|
||||
dueOn: '2026-05-11',
|
||||
createdAt: new Date('2026-05-02T00:00:00Z'),
|
||||
});
|
||||
const tieB = source({ id: 'b', dueOn: '2026-05-11' });
|
||||
const tieA = source({ id: 'a', dueOn: '2026-05-11' });
|
||||
|
||||
const ranked = rankSteps(
|
||||
[sameDayNewer, tieB, early, tieA, sameDayOlder],
|
||||
TODAY,
|
||||
);
|
||||
|
||||
expect(ids(ranked)).toEqual(['z', 'y', 'a', 'b', 'x']);
|
||||
});
|
||||
|
||||
it('does not change its input', () => {
|
||||
const input = [source({ id: 'b' }), source({ id: 'a' })];
|
||||
const copy = [...input];
|
||||
rankSteps(input, TODAY);
|
||||
expect(input).toEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('INTENT_ORDER and FORM_OF_INTENT', () => {
|
||||
it('lists every intent exactly once', () => {
|
||||
expect(new Set(INTENT_ORDER).size).toBe(INTENT_ORDER.length);
|
||||
expect(INTENT_ORDER).toHaveLength(17);
|
||||
expect(INTENT_ORDER[0]).toBe('VARROA_TREAT');
|
||||
});
|
||||
|
||||
it('maps the intents to the capture forms of the spec', () => {
|
||||
const forms: [SuggestionIntent, string][] = [
|
||||
['INSPECT', 'inspection'],
|
||||
['SWARM_CHECK', 'inspection'],
|
||||
['QUEEN_CHECK', 'inspection'],
|
||||
['QUEEN_ACCEPTANCE', 'inspection'],
|
||||
['HONEY_SUPER', 'inspection'],
|
||||
['HEALTH_CHECK', 'inspection'],
|
||||
['VARROA_MEASURE', 'varroa'],
|
||||
['VARROA_RECHECK', 'varroa'],
|
||||
['FEED', 'feeding'],
|
||||
['VARROA_TREAT', 'treatment'],
|
||||
['SUMMER_TREATMENT', 'treatment'],
|
||||
['WINTER_CHECK', 'winter-check'],
|
||||
];
|
||||
for (const [intent, form] of forms) {
|
||||
expect(FORM_OF_INTENT[intent]).toBe(form);
|
||||
}
|
||||
expect(FORM_OF_INTENT.SPLIT_PLAN).toBeUndefined();
|
||||
expect(FORM_OF_INTENT.CLEANUP).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSameForm', () => {
|
||||
it('INSPECT (DO) and SWARM_CHECK merge into one step with two reasons and CAPTURE', () => {
|
||||
const swarm = source({
|
||||
id: 'swarm',
|
||||
intent: 'SWARM_CHECK',
|
||||
mode: 'PLAN',
|
||||
title: 'Schwarmkontrolle',
|
||||
reasons: ['Weiselzellen am 05.05. gesehen.'],
|
||||
dueOn: '2026-05-11',
|
||||
});
|
||||
const inspect = source({
|
||||
id: 'inspect',
|
||||
intent: 'INSPECT',
|
||||
mode: 'DO',
|
||||
title: 'Durchsicht',
|
||||
reasons: [
|
||||
'Letzte Durchsicht vor 8 Tagen.',
|
||||
'In der Schwarmzeit alle 7 Tage.',
|
||||
],
|
||||
dueOn: '2026-05-11',
|
||||
});
|
||||
|
||||
const groups = mergeSameForm(rankSteps([inspect, swarm], TODAY));
|
||||
expect(groups).toHaveLength(1);
|
||||
|
||||
const step = toNextStep(groups[0], TODAY);
|
||||
expect(step.title).toBe('Durchsicht mit Schwarmkontrolle');
|
||||
expect(step.reasons).toEqual([
|
||||
'Weiselzellen am 05.05. gesehen.',
|
||||
'Letzte Durchsicht vor 8 Tagen.',
|
||||
]);
|
||||
expect(step.action).toEqual({
|
||||
type: 'CAPTURE',
|
||||
form: 'inspection',
|
||||
href: '/inspections/new?colonyId=col-1',
|
||||
});
|
||||
expect(step.urgency).toBe('OVERDUE');
|
||||
expect(step.dismissible).toBe(true);
|
||||
});
|
||||
|
||||
it('merges the inspection family, but never HONEY_SUPER (spec example B3)', () => {
|
||||
const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' });
|
||||
const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' });
|
||||
const queen = source({ id: 'queen', intent: 'QUEEN_CHECK', mode: 'PLAN' });
|
||||
const honeyPlan = source({
|
||||
id: 'honey-plan',
|
||||
intent: 'HONEY_SUPER',
|
||||
mode: 'PLAN',
|
||||
});
|
||||
const honeyDo = source({
|
||||
id: 'honey-do',
|
||||
intent: 'HONEY_SUPER',
|
||||
mode: 'DO',
|
||||
});
|
||||
|
||||
const groups = mergeSameForm([queen, swarm, inspect, honeyPlan, honeyDo]);
|
||||
|
||||
expect(groups.map(ids)).toEqual([
|
||||
['queen', 'swarm', 'inspect'],
|
||||
['honey-plan'],
|
||||
['honey-do'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges VARROA_MEASURE and VARROA_RECHECK, not VARROA_TREAT', () => {
|
||||
const measure = source({ id: 'm', intent: 'VARROA_MEASURE', mode: 'DO' });
|
||||
const recheck = source({ id: 'r', intent: 'VARROA_RECHECK', mode: 'DO' });
|
||||
const treat = source({ id: 't', intent: 'VARROA_TREAT', mode: 'DO' });
|
||||
|
||||
expect(mergeSameForm([treat, recheck, measure]).map(ids)).toEqual([
|
||||
['t'],
|
||||
['r', 'm'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a merged step takes the earliest due day, all suggestion ids and is dismissible', () => {
|
||||
const inspect = source({
|
||||
id: 'inspect',
|
||||
intent: 'INSPECT',
|
||||
dueOn: '2026-05-14',
|
||||
title: 'Durchsicht',
|
||||
reasons: ['Letzte Durchsicht vor 8 Tagen.'],
|
||||
});
|
||||
const queen = source({
|
||||
id: 'queen',
|
||||
intent: 'QUEEN_ACCEPTANCE',
|
||||
mode: 'PLAN',
|
||||
dueOn: '2026-05-10',
|
||||
title: 'Annahme der Königin prüfen',
|
||||
reasons: ['Königin am 05.05. zugesetzt.'],
|
||||
});
|
||||
|
||||
const step = toNextStep([inspect, queen], TODAY);
|
||||
|
||||
expect(step.title).toBe('Durchsicht mit Annahme der Königin prüfen');
|
||||
expect(step.dueOn).toBe('2026-05-10');
|
||||
expect(step.urgency).toBe('OVERDUE');
|
||||
expect(step.suggestionIds).toEqual(['inspect', 'queen']);
|
||||
expect(step.dismissible).toBe(true);
|
||||
expect(step.reasons[0]).toContain('zugesetzt');
|
||||
});
|
||||
|
||||
it('an IN_PROGRESS task counts as TODAY', () => {
|
||||
const step = toNextStep(
|
||||
[
|
||||
source({
|
||||
kind: 'TASK',
|
||||
mode: null,
|
||||
intent: null,
|
||||
status: 'IN_PROGRESS',
|
||||
dueOn: '2026-07-01',
|
||||
}),
|
||||
],
|
||||
TODAY,
|
||||
);
|
||||
expect(step.urgency).toBe('TODAY');
|
||||
expect(step.suggestionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('two PLAN steps of the same form do not merge', () => {
|
||||
const a = source({ id: 'a', intent: 'SWARM_CHECK', mode: 'PLAN' });
|
||||
const b = source({ id: 'b', intent: 'QUEEN_ACCEPTANCE', mode: 'PLAN' });
|
||||
|
||||
expect(mergeSameForm([a, b]).map(ids)).toEqual([['a'], ['b']]);
|
||||
});
|
||||
|
||||
it('never merges tasks or steps of other colonies', () => {
|
||||
const doStep = source({ id: 'do', intent: 'INSPECT', mode: 'DO' });
|
||||
const task = source({
|
||||
id: 'task',
|
||||
kind: 'TASK',
|
||||
mode: null,
|
||||
intent: 'SWARM_CHECK',
|
||||
});
|
||||
const otherColony = source({
|
||||
id: 'other',
|
||||
intent: 'SWARM_CHECK',
|
||||
mode: 'PLAN',
|
||||
colonyId: 'col-2',
|
||||
});
|
||||
|
||||
expect(mergeSameForm([doStep, task, otherColony]).map(ids)).toEqual([
|
||||
['do'],
|
||||
['task'],
|
||||
['other'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the rank order of the groups (a group sits where its best member sits)', () => {
|
||||
const varroa = source({
|
||||
id: 'varroa',
|
||||
intent: 'VARROA_TREAT',
|
||||
mode: 'PLAN',
|
||||
});
|
||||
const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' });
|
||||
const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' });
|
||||
const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' });
|
||||
|
||||
const groups = mergeSameForm([varroa, swarm, feed, inspect]);
|
||||
|
||||
expect(groups.map(ids)).toEqual([
|
||||
['varroa'],
|
||||
['swarm', 'inspect'],
|
||||
['feed'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNextStep', () => {
|
||||
it('opens a task with OPEN_TASK and never lets it be dismissed', () => {
|
||||
const step = toNextStep(
|
||||
[
|
||||
source({
|
||||
id: 't1',
|
||||
kind: 'TASK',
|
||||
mode: null,
|
||||
intent: null,
|
||||
title: 'Wachs',
|
||||
}),
|
||||
],
|
||||
TODAY,
|
||||
);
|
||||
expect(step).toMatchObject({
|
||||
key: 'task:t1',
|
||||
kind: 'TASK',
|
||||
intent: null,
|
||||
action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' },
|
||||
dismissible: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a PLAN suggestion and lets it be dismissed', () => {
|
||||
const step = toNextStep(
|
||||
[source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })],
|
||||
TODAY,
|
||||
);
|
||||
expect(step).toMatchObject({
|
||||
key: 'suggestion:s1',
|
||||
action: { type: 'ACCEPT', suggestionId: 's1' },
|
||||
dismissible: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the capture link of a PLAN suggestion when accepting is not allowed', () => {
|
||||
const step = toNextStep(
|
||||
[source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })],
|
||||
TODAY,
|
||||
{ canAccept: false },
|
||||
);
|
||||
expect(step.action).toEqual({
|
||||
type: 'CAPTURE',
|
||||
form: 'treatment',
|
||||
href: '/treatments/new?colonyId=col-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('points a CLEANUP suggestion at the cleanup', () => {
|
||||
const step = toNextStep(
|
||||
[source({ id: 'c1', intent: 'CLEANUP', mode: 'DO' })],
|
||||
TODAY,
|
||||
);
|
||||
expect(step.action).toEqual({ type: 'CLEANUP', suggestionId: 'c1' });
|
||||
});
|
||||
|
||||
it('builds the capture links of every form', () => {
|
||||
const href = (intent: SuggestionIntent, today = TODAY): string => {
|
||||
const { action } = toNextStep([source({ intent, mode: 'DO' })], today);
|
||||
return action.type === 'CAPTURE' ? action.href : 'no-capture';
|
||||
};
|
||||
expect(href('VARROA_MEASURE')).toBe('/varroa/new?colonyId=col-1');
|
||||
expect(href('FEED')).toBe('/feedings/new?colonyId=col-1');
|
||||
expect(href('SUMMER_TREATMENT')).toBe('/treatments/new?colonyId=col-1');
|
||||
expect(href('WINTER_CHECK', '2026-11-20')).toBe(
|
||||
'/winter?colonyId=col-1&season=AUTUMN',
|
||||
);
|
||||
expect(href('WINTER_CHECK', '2026-03-20')).toBe(
|
||||
'/winter?colonyId=col-1&season=SPRING',
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the urgency from the due day', () => {
|
||||
const urgency = (dueOn: string | null) =>
|
||||
toNextStep([source({ dueOn })], TODAY).urgency;
|
||||
expect(urgency('2026-05-11')).toBe('OVERDUE');
|
||||
expect(urgency('2026-05-12')).toBe('TODAY');
|
||||
expect(urgency('2026-05-15')).toBe('SOON');
|
||||
expect(urgency('2026-05-16')).toBe('LATER');
|
||||
expect(urgency(null)).toBe('NONE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('topSteps', () => {
|
||||
it('never returns more than 3 and is stable by id', () => {
|
||||
const items = ['e', 'c', 'a', 'd', 'b'].map((id) =>
|
||||
source({ id, intent: 'FEED', mode: 'PLAN' }),
|
||||
);
|
||||
|
||||
const first = topSteps(items, TODAY, { limit: 3 });
|
||||
const second = topSteps([...items].reverse(), TODAY, { limit: 3 });
|
||||
|
||||
expect(first.map((step) => step.key)).toEqual([
|
||||
'suggestion:a',
|
||||
'suggestion:b',
|
||||
'suggestion:c',
|
||||
]);
|
||||
expect(second.map((step) => step.key)).toEqual(
|
||||
first.map((step) => step.key),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a step the user can neither accept nor capture', () => {
|
||||
const split = source({ id: 'split', intent: 'SPLIT_PLAN', mode: 'PLAN' });
|
||||
const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' });
|
||||
|
||||
const steps = topSteps([split, feed], TODAY, {
|
||||
limit: 3,
|
||||
canAccept: false,
|
||||
});
|
||||
|
||||
expect(steps.map((step) => step.key)).toEqual(['suggestion:feed']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
import type {
|
||||
SuggestionIntent,
|
||||
SuggestionMode,
|
||||
TaskPriority,
|
||||
} from '../../generated/prisma';
|
||||
import { daysBetween } from '../common/dates';
|
||||
import type {
|
||||
PhaseSourceKey,
|
||||
PhenologyPhaseKey,
|
||||
SeasonSegmentKey,
|
||||
} from '../season/season-types';
|
||||
|
||||
export type NextStepForm =
|
||||
'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check';
|
||||
|
||||
export interface NextStep {
|
||||
/** 'task:<id>' | 'suggestion:<id>' */
|
||||
key: string;
|
||||
kind: 'TASK' | 'SUGGESTION';
|
||||
intent: SuggestionIntent | null;
|
||||
title: string;
|
||||
/** At most 2 short sentences. */
|
||||
reasons: string[];
|
||||
/** YYYY-MM-DD */
|
||||
dueOn: string | null;
|
||||
urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE';
|
||||
/** The suggestions behind the step (all members of a merged step); [] for a task. */
|
||||
suggestionIds: string[];
|
||||
action:
|
||||
| { type: 'CAPTURE'; form: NextStepForm; href: string }
|
||||
| { type: 'ACCEPT'; suggestionId: string }
|
||||
| { type: 'CLEANUP'; suggestionId: string }
|
||||
| { type: 'OPEN_TASK'; taskId: string; href: string };
|
||||
dismissible: boolean;
|
||||
}
|
||||
|
||||
export interface ColonyNextSteps {
|
||||
colonyId: string;
|
||||
segment: SeasonSegmentKey;
|
||||
phase: PhenologyPhaseKey | null;
|
||||
phaseSource: PhaseSourceKey | null;
|
||||
inspectionDueOn: string | null;
|
||||
withdrawalUntil: string | null;
|
||||
/** At most `maxStepsPerColony` (3). */
|
||||
steps: NextStep[];
|
||||
}
|
||||
|
||||
/** An open task or an open suggestion of one colony, ready to be ranked. */
|
||||
export interface StepSource {
|
||||
kind: 'TASK' | 'SUGGESTION';
|
||||
id: string;
|
||||
intent: SuggestionIntent | null;
|
||||
mode: SuggestionMode | null;
|
||||
status: string;
|
||||
priority: TaskPriority;
|
||||
/** YYYY-MM-DD */
|
||||
dueOn: string | null;
|
||||
createdAt: Date;
|
||||
title: string;
|
||||
reasons: string[];
|
||||
colonyId: string;
|
||||
}
|
||||
|
||||
/** Spec 8.4 point 3. A task without a suggestion counts as FOLLOW_UP. */
|
||||
export const INTENT_ORDER: readonly SuggestionIntent[] = [
|
||||
'VARROA_TREAT',
|
||||
'QUEEN_CHECK',
|
||||
'QUEEN_ACCEPTANCE',
|
||||
'SWARM_CHECK',
|
||||
'SPLIT_PLAN',
|
||||
'FEED',
|
||||
'INSPECT',
|
||||
'VARROA_RECHECK',
|
||||
'HEALTH_CHECK',
|
||||
'SUMMER_TREATMENT',
|
||||
'VARROA_MEASURE',
|
||||
'WINTER_CHECK',
|
||||
'HONEY_SUPER',
|
||||
'QUEEN_REPLACE',
|
||||
'FOLLOW_UP',
|
||||
'CLEANUP',
|
||||
'STOCK_REORDER',
|
||||
];
|
||||
|
||||
export const FORM_OF_INTENT: Readonly<
|
||||
Partial<Record<SuggestionIntent, NextStepForm>>
|
||||
> = {
|
||||
INSPECT: 'inspection',
|
||||
SWARM_CHECK: 'inspection',
|
||||
QUEEN_CHECK: 'inspection',
|
||||
QUEEN_ACCEPTANCE: 'inspection',
|
||||
HONEY_SUPER: 'inspection',
|
||||
HEALTH_CHECK: 'inspection',
|
||||
VARROA_MEASURE: 'varroa',
|
||||
VARROA_RECHECK: 'varroa',
|
||||
FEED: 'feeding',
|
||||
VARROA_TREAT: 'treatment',
|
||||
SUMMER_TREATMENT: 'treatment',
|
||||
WINTER_CHECK: 'winter-check',
|
||||
};
|
||||
|
||||
/** Title of the merged step; the second title is appended with "mit". */
|
||||
const FORM_TITLES: Readonly<Record<NextStepForm, string>> = {
|
||||
inspection: 'Durchsicht',
|
||||
varroa: 'Varroa messen',
|
||||
feeding: 'Füttern',
|
||||
treatment: 'Behandlung',
|
||||
'winter-check': 'Winterkontrolle',
|
||||
};
|
||||
|
||||
const PRIORITY_RANK: Readonly<Record<TaskPriority, number>> = {
|
||||
URGENT: 0,
|
||||
HIGH: 1,
|
||||
NORMAL: 2,
|
||||
LOW: 3,
|
||||
};
|
||||
|
||||
const INTENT_RANK = new Map(
|
||||
INTENT_ORDER.map((intent, index) => [intent, index] as const),
|
||||
);
|
||||
|
||||
const SOON_DAYS = 3;
|
||||
const LATER_TIER_DAYS = 14;
|
||||
const MAX_REASONS = 2;
|
||||
const FIRST_AUTUMN_MONTH = 7;
|
||||
const TASKS_HREF = '/tasks';
|
||||
const DATELESS_TIER = 4;
|
||||
|
||||
export interface StepOptions {
|
||||
/** The user may accept suggestions (tasks:create). Default true. */
|
||||
canAccept?: boolean;
|
||||
}
|
||||
|
||||
export interface TopStepsOptions extends StepOptions {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
function intentRank(source: StepSource): number {
|
||||
return INTENT_RANK.get(source.intent ?? 'FOLLOW_UP') ?? INTENT_ORDER.length;
|
||||
}
|
||||
|
||||
/** Spec 8.4 point 1: 0 = now, 1 <= 3 days, 2 <= 14 days, 3 later, 4 no date. */
|
||||
function urgencyTier(source: StepSource, today: string): number {
|
||||
if (source.status === 'IN_PROGRESS') {
|
||||
return 0;
|
||||
}
|
||||
if (source.dueOn === null) {
|
||||
return source.priority === 'URGENT' ? 0 : DATELESS_TIER;
|
||||
}
|
||||
const days = daysBetween(today, source.dueOn);
|
||||
if (days <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (days <= SOON_DAYS) {
|
||||
return 1;
|
||||
}
|
||||
return days <= LATER_TIER_DAYS ? 2 : 3;
|
||||
}
|
||||
|
||||
function compareText(a: string, b: string): number {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
|
||||
/** The total order of the spec: urgency, priority, intent, due day, createdAt, id. */
|
||||
export function compareSources(
|
||||
a: StepSource,
|
||||
b: StepSource,
|
||||
today: string,
|
||||
): number {
|
||||
return (
|
||||
urgencyTier(a, today) - urgencyTier(b, today) ||
|
||||
PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] ||
|
||||
intentRank(a) - intentRank(b) ||
|
||||
compareText(a.dueOn ?? '9999-12-31', b.dueOn ?? '9999-12-31') ||
|
||||
a.createdAt.getTime() - b.createdAt.getTime() ||
|
||||
compareText(a.id, b.id)
|
||||
);
|
||||
}
|
||||
|
||||
export function rankSteps(
|
||||
items: readonly StepSource[],
|
||||
today: string,
|
||||
): StepSource[] {
|
||||
return [...items].sort((a, b) => compareSources(a, b, today));
|
||||
}
|
||||
|
||||
function formOf(source: StepSource): NextStepForm | undefined {
|
||||
return source.kind === 'SUGGESTION' && source.intent !== null
|
||||
? FORM_OF_INTENT[source.intent]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intents that merge into one step with the "plain" member of their family
|
||||
* (spec 8.4 says: same form = one step; the B3 example of spec 8.5 shows
|
||||
* "Durchsicht mit Schwarmkontrolle" next to a separate "Honigraum prüfen",
|
||||
* although both open the inspection form - the example wins).
|
||||
* inspection family: INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE
|
||||
* varroa family: VARROA_MEASURE, VARROA_RECHECK
|
||||
* Everything else (HONEY_SUPER, HEALTH_CHECK, FEED, VARROA_TREAT,
|
||||
* SUMMER_TREATMENT, WINTER_CHECK ...) never merges.
|
||||
*/
|
||||
const MERGE_FAMILY: Readonly<Partial<Record<SuggestionIntent, NextStepForm>>> =
|
||||
{
|
||||
INSPECT: 'inspection',
|
||||
SWARM_CHECK: 'inspection',
|
||||
QUEEN_CHECK: 'inspection',
|
||||
QUEEN_ACCEPTANCE: 'inspection',
|
||||
VARROA_MEASURE: 'varroa',
|
||||
VARROA_RECHECK: 'varroa',
|
||||
};
|
||||
|
||||
/** The member whose title is the form's own title, by intent. */
|
||||
const PLAIN_INTENT: Readonly<
|
||||
Record<'inspection' | 'varroa', SuggestionIntent>
|
||||
> = { inspection: 'INSPECT', varroa: 'VARROA_MEASURE' };
|
||||
|
||||
function familyOf(source: StepSource): NextStepForm | undefined {
|
||||
return source.kind === 'SUGGESTION' && source.intent !== null
|
||||
? MERGE_FAMILY[source.intent]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggestions of one merge family (table above) of one colony become one
|
||||
* step, when at least one of them is a DO suggestion (PLAN suggestions alone
|
||||
* are decisions, not a form to fill in). The group keeps the position of its
|
||||
* best-ranked member; `ranked` must already be ranked.
|
||||
*/
|
||||
export function mergeSameForm(ranked: readonly StepSource[]): StepSource[][] {
|
||||
const members = new Map<string, StepSource[]>();
|
||||
for (const item of ranked) {
|
||||
const family = familyOf(item);
|
||||
if (family !== undefined) {
|
||||
const key = `${item.colonyId}|${family}`;
|
||||
members.set(key, [...(members.get(key) ?? []), item]);
|
||||
}
|
||||
}
|
||||
const groups: StepSource[][] = [];
|
||||
const placed = new Set<string>();
|
||||
for (const item of ranked) {
|
||||
const family = familyOf(item);
|
||||
const key = family === undefined ? null : `${item.colonyId}|${family}`;
|
||||
const group = key === null ? undefined : members.get(key);
|
||||
const mergeable =
|
||||
group !== undefined &&
|
||||
group.length > 1 &&
|
||||
group.some((member) => member.mode === 'DO');
|
||||
if (key === null || !mergeable) {
|
||||
groups.push([item]);
|
||||
} else if (!placed.has(key)) {
|
||||
placed.add(key);
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function urgencyOf(dueOn: string | null, today: string): NextStep['urgency'] {
|
||||
if (dueOn === null) {
|
||||
return 'NONE';
|
||||
}
|
||||
const days = daysBetween(today, dueOn);
|
||||
if (days < 0) {
|
||||
return 'OVERDUE';
|
||||
}
|
||||
if (days === 0) {
|
||||
return 'TODAY';
|
||||
}
|
||||
return days <= SOON_DAYS ? 'SOON' : 'LATER';
|
||||
}
|
||||
|
||||
function captureHref(
|
||||
form: NextStepForm,
|
||||
colonyId: string,
|
||||
today: string,
|
||||
): string {
|
||||
const query = `colonyId=${encodeURIComponent(colonyId)}`;
|
||||
switch (form) {
|
||||
case 'inspection':
|
||||
return `/inspections/new?${query}`;
|
||||
case 'varroa':
|
||||
return `/varroa/new?${query}`;
|
||||
case 'feeding':
|
||||
return `/feedings/new?${query}`;
|
||||
case 'treatment':
|
||||
return `/treatments/new?${query}`;
|
||||
case 'winter-check': {
|
||||
const month = Number(today.slice(5, 7));
|
||||
const season = month >= FIRST_AUTUMN_MONTH ? 'AUTUMN' : 'SPRING';
|
||||
return `/winter?${query}&season=${season}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function capture(
|
||||
form: NextStepForm,
|
||||
colonyId: string,
|
||||
today: string,
|
||||
): NextStep['action'] {
|
||||
return {
|
||||
type: 'CAPTURE',
|
||||
form,
|
||||
href: captureHref(form, colonyId, today),
|
||||
};
|
||||
}
|
||||
|
||||
function actionOf(
|
||||
source: StepSource,
|
||||
today: string,
|
||||
canAccept: boolean,
|
||||
): NextStep['action'] | null {
|
||||
if (source.kind === 'TASK') {
|
||||
return { type: 'OPEN_TASK', taskId: source.id, href: TASKS_HREF };
|
||||
}
|
||||
if (source.intent === 'CLEANUP') {
|
||||
return { type: 'CLEANUP', suggestionId: source.id };
|
||||
}
|
||||
if (source.mode === 'PLAN' && canAccept) {
|
||||
return { type: 'ACCEPT', suggestionId: source.id };
|
||||
}
|
||||
const form = formOf(source);
|
||||
return form === undefined ? null : capture(form, source.colonyId, today);
|
||||
}
|
||||
|
||||
/** Whether the user can do anything with this step (else it is not shown). */
|
||||
function isActionable(
|
||||
source: StepSource,
|
||||
today: string,
|
||||
canAccept: boolean,
|
||||
): boolean {
|
||||
return actionOf(source, today, canAccept) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The specific cause first (the step that is not the plain form, "Weiselzellen
|
||||
* gesehen"), then the plain one ("Letzte Durchsicht vor 8 Tagen").
|
||||
*/
|
||||
function isPlain(form: NextStepForm, item: StepSource): boolean {
|
||||
return (
|
||||
(form === 'inspection' || form === 'varroa') &&
|
||||
item.intent === PLAIN_INTENT[form]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The specific cause first ("Weiselzellen gesehen"), then the plain member
|
||||
* ("Letzte Durchsicht vor 8 Tagen"), at most two.
|
||||
*/
|
||||
function mergedReasons(
|
||||
form: NextStepForm,
|
||||
group: readonly StepSource[],
|
||||
): string[] {
|
||||
const ordered = [
|
||||
...group.filter((item) => !isPlain(form, item)),
|
||||
...group.filter((item) => isPlain(form, item)),
|
||||
];
|
||||
const firsts = ordered.flatMap((item) => item.reasons.slice(0, 1));
|
||||
const rest = ordered.flatMap((item) => item.reasons.slice(1));
|
||||
return [...new Set([...firsts, ...rest])].slice(0, MAX_REASONS);
|
||||
}
|
||||
|
||||
function mergedTitle(form: NextStepForm, group: readonly StepSource[]): string {
|
||||
const second = group.find((item) => !isPlain(form, item));
|
||||
const formTitle = FORM_TITLES[form];
|
||||
return second === undefined ? formTitle : `${formTitle} mit ${second.title}`;
|
||||
}
|
||||
|
||||
/** The earliest due day of the members (null when none has one). */
|
||||
function earliestDue(group: readonly StepSource[]): string | null {
|
||||
const days = group.flatMap((item) =>
|
||||
item.dueOn === null ? [] : [item.dueOn],
|
||||
);
|
||||
return days.length === 0 ? null : days.reduce((a, b) => (a <= b ? a : b));
|
||||
}
|
||||
|
||||
/** One step from a ranked group (a single item, or a merged group). */
|
||||
export function toNextStep(
|
||||
group: readonly StepSource[],
|
||||
today: string,
|
||||
options: StepOptions = {},
|
||||
): NextStep {
|
||||
const [head] = group;
|
||||
const canAccept = options.canAccept ?? true;
|
||||
const form = formOf(head);
|
||||
if (group.length > 1 && form !== undefined) {
|
||||
return {
|
||||
key: `suggestion:${head.id}`,
|
||||
kind: 'SUGGESTION',
|
||||
intent: head.intent,
|
||||
title: mergedTitle(form, group),
|
||||
reasons: mergedReasons(form, group),
|
||||
dueOn: earliestDue(group),
|
||||
urgency: urgencyOf(earliestDue(group), today),
|
||||
suggestionIds: group.map((item) => item.id),
|
||||
action: capture(form, head.colonyId, today),
|
||||
dismissible: true,
|
||||
};
|
||||
}
|
||||
const action = actionOf(head, today, canAccept);
|
||||
if (action === null) {
|
||||
throw new Error(`Step ${head.id} has no action`);
|
||||
}
|
||||
return {
|
||||
key: `${head.kind === 'TASK' ? 'task' : 'suggestion'}:${head.id}`,
|
||||
kind: head.kind,
|
||||
intent: head.intent,
|
||||
title: head.title,
|
||||
reasons: head.reasons.slice(0, MAX_REASONS),
|
||||
dueOn: head.dueOn,
|
||||
urgency:
|
||||
head.status === 'IN_PROGRESS' ? 'TODAY' : urgencyOf(head.dueOn, today),
|
||||
suggestionIds: head.kind === 'SUGGESTION' ? [head.id] : [],
|
||||
action,
|
||||
dismissible: head.kind === 'SUGGESTION',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The next steps of one colony: what the user can act on, ranked, merged by
|
||||
* form and cut to `limit`.
|
||||
*/
|
||||
export function topSteps(
|
||||
items: readonly StepSource[],
|
||||
today: string,
|
||||
options: TopStepsOptions,
|
||||
): NextStep[] {
|
||||
const canAccept = options.canAccept ?? true;
|
||||
const actionable = items.filter((item) =>
|
||||
isActionable(item, today, canAccept),
|
||||
);
|
||||
return mergeSameForm(rankSteps(actionable, today))
|
||||
.slice(0, options.limit)
|
||||
.map((group) => toNextStep(group, today, { canAccept }));
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
|
||||
import { NextStepsService } from './next-steps.service';
|
||||
|
||||
const TODAY = '2026-05-12';
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
describe('NextStepsService', () => {
|
||||
let service: NextStepsService;
|
||||
let prisma: {
|
||||
colony: { findMany: jest.Mock };
|
||||
suggestion: { findMany: jest.Mock; groupBy: jest.Mock; count: jest.Mock };
|
||||
task: { findMany: jest.Mock };
|
||||
inspection: { groupBy: jest.Mock };
|
||||
treatment: { findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
apiary: { findMany: jest.Mock };
|
||||
};
|
||||
let permissionResolver: { getPermissions: jest.Mock };
|
||||
|
||||
const grant = (...keys: string[]) =>
|
||||
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
|
||||
|
||||
const suggestionRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 's1',
|
||||
ruleKey: 'VARROA_THRESHOLD',
|
||||
reasonCode: 'UNKNOWN_CODE',
|
||||
evidence: {},
|
||||
intent: 'VARROA_TREAT',
|
||||
mode: 'PLAN',
|
||||
status: 'OPEN',
|
||||
priority: 'HIGH',
|
||||
dueDate: d('2026-05-12'),
|
||||
createdAt: new Date('2026-05-10T08:00:00Z'),
|
||||
colonyId: 'c1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
colony: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'c1',
|
||||
code: 'B3',
|
||||
apiaryId: 'a1',
|
||||
createdAt: new Date('2026-04-01T08:00:00Z'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
suggestion: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
inspection: {
|
||||
groupBy: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ colonyId: 'c1', _max: { date: d('2026-05-03') } },
|
||||
]),
|
||||
},
|
||||
treatment: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
apiary: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
permissionResolver = { getPermissions: jest.fn() };
|
||||
service = new NextStepsService(
|
||||
prisma as unknown as PrismaService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
new SeasonService(),
|
||||
new SuggestionViewsService(
|
||||
prisma as unknown as PrismaService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown colony', async () => {
|
||||
grant('colonies:read');
|
||||
prisma.colony.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(service.forColony('nope', 'u1', TODAY)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads no suggestions and no tasks without tasks:read', async () => {
|
||||
grant('colonies:read', 'varroa:read');
|
||||
|
||||
const steps = await service.forColony('c1', 'u1', TODAY);
|
||||
|
||||
expect(steps.steps).toEqual([]);
|
||||
expect(prisma.suggestion.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.task.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters the suggestions by the permissions the user holds', async () => {
|
||||
grant('colonies:read', 'tasks:read', 'inspections:read');
|
||||
|
||||
await service.forColony('c1', 'u1', TODAY);
|
||||
|
||||
expect(prisma.suggestion.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ requiredPermission: null },
|
||||
{
|
||||
requiredPermission: {
|
||||
in: ['colonies:read', 'tasks:read', 'inspections:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 'OPEN', colonyId: { in: ['c1'] } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers ACCEPT only with tasks:create, else the capture link', async () => {
|
||||
prisma.suggestion.findMany.mockResolvedValue([suggestionRow()]);
|
||||
|
||||
grant('colonies:read', 'tasks:read', 'tasks:create');
|
||||
const withCreate = await service.forColony('c1', 'u1', TODAY);
|
||||
grant('colonies:read', 'tasks:read');
|
||||
const withoutCreate = await service.forColony('c1', 'u1', TODAY);
|
||||
|
||||
expect(withCreate.steps[0].action).toEqual({
|
||||
type: 'ACCEPT',
|
||||
suggestionId: 's1',
|
||||
});
|
||||
expect(withoutCreate.steps[0].action).toEqual({
|
||||
type: 'CAPTURE',
|
||||
form: 'treatment',
|
||||
href: '/treatments/new?colonyId=c1',
|
||||
});
|
||||
});
|
||||
|
||||
it('cuts the steps to maxStepsPerColony', async () => {
|
||||
grant('colonies:read', 'tasks:read');
|
||||
prisma.suggestion.findMany.mockResolvedValue(
|
||||
[
|
||||
['a', 'WINTER_CHECK'],
|
||||
['b', 'VARROA_MEASURE'],
|
||||
['c', 'INSPECT'],
|
||||
['d', 'FEED'],
|
||||
['e', 'VARROA_TREAT'],
|
||||
].map(([id, intent]) => suggestionRow({ id, intent, mode: 'DO' })),
|
||||
);
|
||||
|
||||
const steps = await service.forColony('c1', 'u1', TODAY);
|
||||
|
||||
expect(steps.steps.map((step) => step.key)).toEqual([
|
||||
'suggestion:e',
|
||||
'suggestion:d',
|
||||
'suggestion:c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows inspectionDueOn only with inspections:read', async () => {
|
||||
grant('colonies:read');
|
||||
const hidden = await service.forColony('c1', 'u1', TODAY);
|
||||
grant('colonies:read', 'inspections:read');
|
||||
const shown = await service.forColony('c1', 'u1', TODAY);
|
||||
|
||||
expect(hidden.inspectionDueOn).toBeNull();
|
||||
// Swarm season by calendar: 7 days after 2026-05-03.
|
||||
expect(shown.inspectionDueOn).toBe('2026-05-10');
|
||||
expect(shown.segment).toBe('SCHWARMZEIT');
|
||||
expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the latest running withdrawal period as a hint with treatments:read', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
colonyId: 'c1',
|
||||
product: 'A',
|
||||
date: d('2026-05-01'),
|
||||
startDate: null,
|
||||
endDate: d('2026-05-05'),
|
||||
withdrawalPeriodDays: 10,
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
colonyId: 'c1',
|
||||
product: 'B',
|
||||
date: d('2026-05-02'),
|
||||
startDate: null,
|
||||
endDate: d('2026-05-08'),
|
||||
withdrawalPeriodDays: 30,
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
colonyId: 'c1',
|
||||
product: 'C',
|
||||
date: d('2026-01-02'),
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 10,
|
||||
},
|
||||
]);
|
||||
|
||||
grant('colonies:read', 'treatments:read');
|
||||
const shown = await service.forColony('c1', 'u1', TODAY);
|
||||
grant('colonies:read');
|
||||
const hidden = await service.forColony('c1', 'u1', TODAY);
|
||||
|
||||
expect(shown.withdrawalUntil).toBe('2026-06-07');
|
||||
expect(shown.steps).toEqual([]);
|
||||
expect(hidden.withdrawalUntil).toBeNull();
|
||||
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ranks a task like its intent and shows it with OPEN_TASK', async () => {
|
||||
grant('colonies:read', 'tasks:read');
|
||||
prisma.task.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Wachs einschmelzen',
|
||||
status: 'IN_PROGRESS',
|
||||
priority: 'NORMAL',
|
||||
dueDate: null,
|
||||
createdAt: new Date('2026-05-01T00:00:00Z'),
|
||||
relatedId: 'c1',
|
||||
sourceAutomationTrigger: null,
|
||||
sourceSuggestion: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const steps = await service.forColony('c1', 'u1', TODAY);
|
||||
|
||||
expect(steps.steps).toHaveLength(1);
|
||||
expect(steps.steps[0]).toMatchObject({
|
||||
key: 'task:t1',
|
||||
title: 'Wachs einschmelzen',
|
||||
action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' },
|
||||
});
|
||||
});
|
||||
|
||||
it('hides an open suggestion when an open task of the colony has the same intent', async () => {
|
||||
grant('colonies:read', 'tasks:read');
|
||||
prisma.suggestion.findMany.mockResolvedValue([
|
||||
suggestionRow({ id: 'varroa', intent: 'VARROA_TREAT' }),
|
||||
suggestionRow({ id: 'feed', intent: 'FEED', mode: 'DO' }),
|
||||
suggestionRow({ id: 'swarm', intent: 'SWARM_CHECK', colonyId: 'c9' }),
|
||||
]);
|
||||
prisma.task.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'task-accepted',
|
||||
title: 'Behandeln',
|
||||
status: 'OPEN',
|
||||
priority: 'NORMAL',
|
||||
dueDate: null,
|
||||
createdAt: new Date('2026-05-01T00:00:00Z'),
|
||||
relatedId: 'c1',
|
||||
sourceAutomationTrigger: null,
|
||||
sourceSuggestion: { intent: 'VARROA_TREAT' },
|
||||
},
|
||||
{
|
||||
id: 'task-legacy',
|
||||
title: 'Füttern',
|
||||
status: 'OPEN',
|
||||
priority: 'NORMAL',
|
||||
dueDate: null,
|
||||
createdAt: new Date('2026-05-01T00:00:00Z'),
|
||||
relatedId: 'c1',
|
||||
sourceAutomationTrigger: 'FEED_STATUS_CRITICAL',
|
||||
sourceSuggestion: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const steps = await service.forColony('c1', 'u1', TODAY);
|
||||
|
||||
expect(steps.steps.map((step) => step.key).sort()).toEqual([
|
||||
'task:task-accepted',
|
||||
'task:task-legacy',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a fixed number of queries for many colonies', async () => {
|
||||
grant('colonies:read', 'tasks:read', 'inspections:read', 'treatments:read');
|
||||
prisma.colony.findMany.mockResolvedValue(
|
||||
['c1', 'c2', 'c3'].map((id) => ({
|
||||
id,
|
||||
code: id,
|
||||
apiaryId: 'a1',
|
||||
createdAt: new Date('2026-04-01T08:00:00Z'),
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await service.forColonies(['c1', 'c2', 'c3'], 'u1', TODAY);
|
||||
|
||||
expect([...result.keys()]).toEqual(['c1', 'c2', 'c3']);
|
||||
expect(prisma.colony.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.suggestion.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.task.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('today', () => {
|
||||
it('picks the most urgent first step over all colonies', async () => {
|
||||
grant('colonies:read', 'tasks:read');
|
||||
prisma.colony.findMany.mockImplementation((args: { select: object }) =>
|
||||
Promise.resolve(
|
||||
'code' in args.select
|
||||
? ['c1', 'c2'].map((id) => ({
|
||||
id,
|
||||
code: id.toUpperCase(),
|
||||
apiaryId: 'a1',
|
||||
createdAt: new Date('2026-04-01T08:00:00Z'),
|
||||
}))
|
||||
: [{ id: 'c1' }, { id: 'c2' }],
|
||||
),
|
||||
);
|
||||
prisma.apiary.findMany.mockResolvedValue([
|
||||
{ id: 'a1', name: 'Heimstand' },
|
||||
]);
|
||||
prisma.suggestion.count.mockResolvedValue(2);
|
||||
prisma.suggestion.findMany.mockResolvedValue([
|
||||
suggestionRow({ id: 'late', colonyId: 'c1', priority: 'LOW' }),
|
||||
suggestionRow({ id: 'urgent', colonyId: 'c2', priority: 'URGENT' }),
|
||||
]);
|
||||
|
||||
const today = await service.today('u1', TODAY);
|
||||
|
||||
expect(today.nextStep).toMatchObject({
|
||||
key: 'suggestion:urgent',
|
||||
colonyId: 'c2',
|
||||
colonyCode: 'C2',
|
||||
});
|
||||
expect(today.suggestionsOpen).toBe(2);
|
||||
expect(today.tasksVisible).toBe(true);
|
||||
expect(today.seasons).toEqual([
|
||||
{
|
||||
apiaryId: 'a1',
|
||||
apiaryName: 'Heimstand',
|
||||
segment: 'SCHWARMZEIT',
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'CALENDAR',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no next step and counts no suggestions without tasks:read', async () => {
|
||||
grant('colonies:read');
|
||||
|
||||
const today = await service.today('u1', TODAY);
|
||||
|
||||
expect(today.nextStep).toBeNull();
|
||||
expect(today.suggestionsOpen).toBe(0);
|
||||
expect(today.tasksVisible).toBe(false);
|
||||
expect(prisma.suggestion.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists only colonies whose inspection is due, with the overdue days', async () => {
|
||||
grant('colonies:read', 'inspections:read');
|
||||
prisma.apiary.findMany.mockResolvedValue([
|
||||
{ id: 'a1', name: 'Heimstand' },
|
||||
]);
|
||||
|
||||
const today = await service.today('u1', TODAY);
|
||||
|
||||
expect(today.inspectionDue).toEqual([
|
||||
{
|
||||
apiaryId: 'a1',
|
||||
apiaryName: 'Heimstand',
|
||||
segment: 'SCHWARMZEIT',
|
||||
intervalDays: 7,
|
||||
colonies: [
|
||||
{ id: 'c1', code: 'B3', dueOn: '2026-05-10', overdueDays: 2 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningCounts', () => {
|
||||
it('counts open HIGH and URGENT suggestions per colony', async () => {
|
||||
grant('colonies:read', 'tasks:read');
|
||||
prisma.suggestion.groupBy.mockResolvedValue([
|
||||
{ colonyId: 'c1', priority: 'HIGH', _count: { _all: 2 } },
|
||||
{ colonyId: 'c1', priority: 'URGENT', _count: { _all: 1 } },
|
||||
{ colonyId: null, priority: 'HIGH', _count: { _all: 5 } },
|
||||
]);
|
||||
|
||||
const counts = await service.warningCounts(['c1'], 'u1');
|
||||
|
||||
expect(counts).toEqual(new Map([['c1', { warnings: 2, critical: 1 }]]));
|
||||
});
|
||||
|
||||
it('counts nothing without tasks:read', async () => {
|
||||
grant('colonies:read');
|
||||
|
||||
const counts = await service.warningCounts(['c1'], 'u1');
|
||||
|
||||
expect(counts.size).toBe(0);
|
||||
expect(prisma.suggestion.groupBy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,585 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
AutomationTriggerType,
|
||||
SuggestionIntent,
|
||||
TaskPriority,
|
||||
} from '../../generated/prisma';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import {
|
||||
addDays,
|
||||
berlinDayOf,
|
||||
berlinToday,
|
||||
daysBetween,
|
||||
dayToDate,
|
||||
} from '../common/dates';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { inspectionDueOn } from '../season/inspection-due';
|
||||
import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
|
||||
import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
|
||||
import { renderSuggestion } from '../suggestions/suggestion-texts';
|
||||
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
|
||||
import {
|
||||
INTENT_OF_LEGACY_TRIGGER,
|
||||
type SuggestionEvidence,
|
||||
} from '../suggestions/suggestion.types';
|
||||
import {
|
||||
isInWindow,
|
||||
withdrawalWindowOf,
|
||||
} from '../treatments/withdrawal-window';
|
||||
import {
|
||||
compareSources,
|
||||
topSteps,
|
||||
type ColonyNextSteps,
|
||||
type NextStep,
|
||||
type StepSource,
|
||||
} from './next-step-ranking';
|
||||
|
||||
export interface AutopilotToday {
|
||||
nextStep: (NextStep & { colonyId: string; colonyCode: string }) | null;
|
||||
/** Colonies whose inspection is due today or overdue (`dueOn <= today`), not those coming up. */
|
||||
inspectionDue: {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
segment: SeasonSegmentKey;
|
||||
intervalDays: number | null;
|
||||
colonies: {
|
||||
id: string;
|
||||
code: string;
|
||||
dueOn: string;
|
||||
overdueDays: number;
|
||||
}[];
|
||||
}[];
|
||||
suggestionsOpen: number;
|
||||
seasons: {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
segment: SeasonSegmentKey;
|
||||
phase: ColonyNextSteps['phase'];
|
||||
phaseSource: ColonyNextSteps['phaseSource'];
|
||||
}[];
|
||||
tasksVisible: boolean;
|
||||
}
|
||||
|
||||
/** Open suggestions of HIGH / URGENT priority that the caller may see. */
|
||||
export interface WarningCount {
|
||||
warnings: number;
|
||||
critical: number;
|
||||
}
|
||||
|
||||
interface ColonyComputation {
|
||||
code: string;
|
||||
apiaryId: string;
|
||||
/** Leading source of the first step, for the comparison across colonies. */
|
||||
head: StepSource | null;
|
||||
view: ColonyNextSteps;
|
||||
}
|
||||
|
||||
interface Computation {
|
||||
colonies: Map<string, ColonyComputation>;
|
||||
seasons: Map<string, SeasonState>;
|
||||
intervalDays: Readonly<
|
||||
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>
|
||||
>;
|
||||
}
|
||||
|
||||
/** A withdrawal period longer than this is not looked for. */
|
||||
const WITHDRAWAL_LOOKBACK_DAYS = 400;
|
||||
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
|
||||
const WARNING_PRIORITIES: readonly TaskPriority[] = ['HIGH', 'URGENT'];
|
||||
|
||||
/** The intent an open task stands for; null for a plain task. */
|
||||
function taskIntentOf(task: {
|
||||
sourceSuggestion: { intent: SuggestionIntent } | null;
|
||||
sourceAutomationTrigger: AutomationTriggerType | null;
|
||||
}): SuggestionIntent | null {
|
||||
return (
|
||||
task.sourceSuggestion?.intent ??
|
||||
(task.sourceAutomationTrigger === null
|
||||
? null
|
||||
: (INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger] ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
function byCode(a: { code: string }, b: { code: string }): number {
|
||||
return a.code.localeCompare(b.code, 'de', { numeric: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* "The next 3 steps" of a colony (Spec 8.4, 8.5) and the day overview: what
|
||||
* the user may see of the open tasks and suggestions, ranked, merged by form
|
||||
* and cut to `maxStepsPerColony`. Reads only; a fixed number of queries for
|
||||
* any number of colonies.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NextStepsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly seasons: SeasonService,
|
||||
private readonly suggestionViews: SuggestionViewsService,
|
||||
) {}
|
||||
|
||||
async forColony(
|
||||
colonyId: string,
|
||||
userId: string,
|
||||
today: string = berlinToday(),
|
||||
): Promise<ColonyNextSteps> {
|
||||
const view = (await this.forColonies([colonyId], userId, today)).get(
|
||||
colonyId,
|
||||
);
|
||||
if (view === undefined) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Unknown colonies are left out of the map. */
|
||||
async forColonies(
|
||||
colonyIds: readonly string[],
|
||||
userId: string,
|
||||
today: string = berlinToday(),
|
||||
permissions?: ReadonlySet<string>,
|
||||
): Promise<Map<string, ColonyNextSteps>> {
|
||||
const held =
|
||||
permissions ?? (await this.permissionResolver.getPermissions(userId));
|
||||
const { colonies } = await this.compute(colonyIds, held, today);
|
||||
return new Map(
|
||||
[...colonies].map(([id, colony]) => [id, colony.view] as const),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open HIGH / URGENT suggestions per colony: the "warnings" and
|
||||
* "critical" of the colony traffic light. Only what the user may see
|
||||
* (`tasks:read` and the permission of each suggestion).
|
||||
*/
|
||||
async warningCounts(
|
||||
colonyIds: readonly string[],
|
||||
userId: string,
|
||||
permissions?: ReadonlySet<string>,
|
||||
): Promise<Map<string, WarningCount>> {
|
||||
const held =
|
||||
permissions ?? (await this.permissionResolver.getPermissions(userId));
|
||||
const counts = new Map<string, WarningCount>();
|
||||
if (!held.has('tasks:read') || colonyIds.length === 0) {
|
||||
return counts;
|
||||
}
|
||||
const groups = await this.prisma.suggestion.groupBy({
|
||||
by: ['colonyId', 'priority'],
|
||||
where: {
|
||||
AND: [
|
||||
this.suggestionViews.visibleWhere(held),
|
||||
{
|
||||
status: 'OPEN',
|
||||
colonyId: { in: [...colonyIds] },
|
||||
priority: { in: [...WARNING_PRIORITIES] },
|
||||
},
|
||||
],
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
for (const group of groups) {
|
||||
if (group.colonyId === null) {
|
||||
continue;
|
||||
}
|
||||
const current = counts.get(group.colonyId) ?? {
|
||||
warnings: 0,
|
||||
critical: 0,
|
||||
};
|
||||
counts.set(
|
||||
group.colonyId,
|
||||
group.priority === 'URGENT'
|
||||
? { ...current, critical: group._count._all }
|
||||
: { ...current, warnings: group._count._all },
|
||||
);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async today(
|
||||
userId: string,
|
||||
today: string = berlinToday(),
|
||||
): Promise<AutopilotToday> {
|
||||
const held = await this.permissionResolver.getPermissions(userId);
|
||||
const living = await this.prisma.colony.findMany({
|
||||
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 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')
|
||||
? await this.prisma.suggestion.count({
|
||||
where: {
|
||||
AND: [this.suggestionViews.visibleWhere(held), { status: 'OPEN' }],
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
const sortedApiaries = [...seasons.values()].sort((a, b) =>
|
||||
nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', {
|
||||
numeric: true,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
nextStep: this.mostUrgent(colonies, today),
|
||||
inspectionDue: sortedApiaries.flatMap((season) => {
|
||||
const due = [...colonies]
|
||||
.filter(
|
||||
([, c]) =>
|
||||
c.apiaryId === season.apiaryId &&
|
||||
c.view.inspectionDueOn !== null &&
|
||||
c.view.inspectionDueOn <= today,
|
||||
)
|
||||
.map(([id, c]) => ({
|
||||
id,
|
||||
code: c.code,
|
||||
dueOn: c.view.inspectionDueOn ?? today,
|
||||
overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today),
|
||||
}))
|
||||
.sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b));
|
||||
return due.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
apiaryId: season.apiaryId,
|
||||
apiaryName: nameOf(season.apiaryId),
|
||||
segment: season.segment,
|
||||
intervalDays:
|
||||
season.segment === 'WINTERRUHE'
|
||||
? null
|
||||
: intervalDays[season.segment],
|
||||
colonies: due,
|
||||
},
|
||||
];
|
||||
}),
|
||||
suggestionsOpen,
|
||||
seasons: sortedApiaries.map((season) => ({
|
||||
apiaryId: season.apiaryId,
|
||||
apiaryName: nameOf(season.apiaryId),
|
||||
segment: season.segment,
|
||||
phase: season.phase,
|
||||
phaseSource: season.phaseSource,
|
||||
})),
|
||||
tasksVisible: held.has('tasks:read'),
|
||||
};
|
||||
}
|
||||
|
||||
private mostUrgent(
|
||||
colonies: ReadonlyMap<string, ColonyComputation>,
|
||||
today: string,
|
||||
): AutopilotToday['nextStep'] {
|
||||
const candidates = [...colonies].flatMap(([colonyId, colony]) =>
|
||||
colony.head === null || colony.view.steps.length === 0
|
||||
? []
|
||||
: [{ colonyId, colony, head: colony.head }],
|
||||
);
|
||||
candidates.sort((a, b) => compareSources(a.head, b.head, today));
|
||||
const best = candidates.at(0);
|
||||
return best === undefined
|
||||
? null
|
||||
: {
|
||||
...best.colony.view.steps[0],
|
||||
colonyId: best.colonyId,
|
||||
colonyCode: best.colony.code,
|
||||
};
|
||||
}
|
||||
|
||||
private async compute(
|
||||
colonyIds: readonly string[],
|
||||
held: ReadonlySet<string>,
|
||||
today: string,
|
||||
): Promise<Computation> {
|
||||
const ids = [...new Set(colonyIds)];
|
||||
const rows =
|
||||
ids.length === 0
|
||||
? []
|
||||
: await this.prisma.colony.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, code: true, apiaryId: true, createdAt: true },
|
||||
});
|
||||
const [states, interval, autopilot] = await Promise.all([
|
||||
this.seasons.statesFor(
|
||||
this.prisma,
|
||||
rows.map((row) => row.apiaryId),
|
||||
today,
|
||||
),
|
||||
readRule(this.prisma, 'INSPECTION_INTERVAL'),
|
||||
readRule(this.prisma, 'SEASON_AUTOPILOT'),
|
||||
]);
|
||||
const foundIds = rows.map((row) => row.id);
|
||||
const [lastInspections, sources, withdrawals] = await Promise.all([
|
||||
this.lastInspectionDays(foundIds, held),
|
||||
this.stepSources(foundIds, held, today),
|
||||
this.withdrawalUntil(foundIds, held, today),
|
||||
]);
|
||||
const colonies = new Map<string, ColonyComputation>();
|
||||
for (const row of rows) {
|
||||
const season = states.get(row.apiaryId);
|
||||
if (season === undefined) {
|
||||
continue;
|
||||
}
|
||||
const due = interval.active
|
||||
? inspectionDueOn(
|
||||
{
|
||||
createdOn: berlinDayOf(row.createdAt),
|
||||
lastInspectedOn: lastInspections.get(row.id) ?? null,
|
||||
},
|
||||
season,
|
||||
interval.config,
|
||||
)
|
||||
: null;
|
||||
const own = sources.get(row.id) ?? [];
|
||||
const steps = topSteps(own, today, {
|
||||
limit: autopilot.config.maxStepsPerColony,
|
||||
canAccept: held.has('tasks:create'),
|
||||
});
|
||||
colonies.set(row.id, {
|
||||
code: row.code,
|
||||
apiaryId: row.apiaryId,
|
||||
head: this.headOf(own, steps),
|
||||
view: {
|
||||
colonyId: row.id,
|
||||
segment: season.segment,
|
||||
phase: season.phase,
|
||||
phaseSource: season.phaseSource,
|
||||
inspectionDueOn:
|
||||
due !== null && held.has('inspections:read') ? due.dueOn : null,
|
||||
withdrawalUntil: withdrawals.get(row.id) ?? null,
|
||||
steps,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
colonies,
|
||||
seasons: states,
|
||||
intervalDays: interval.config.intervalDays,
|
||||
};
|
||||
}
|
||||
|
||||
/** The source that stands for the first step (for the cross-colony order). */
|
||||
private headOf(
|
||||
own: readonly StepSource[],
|
||||
steps: readonly NextStep[],
|
||||
): StepSource | null {
|
||||
const first = steps.at(0);
|
||||
if (first === undefined) {
|
||||
return null;
|
||||
}
|
||||
const byId = new Map(own.map((source) => [source.id, source] as const));
|
||||
const id =
|
||||
first.suggestionIds.at(0) ??
|
||||
(first.action.type === 'OPEN_TASK' ? first.action.taskId : null);
|
||||
return id === null ? null : (byId.get(id) ?? null);
|
||||
}
|
||||
|
||||
/** Last inspection day per colony; nothing without `inspections:read`. */
|
||||
private async lastInspectionDays(
|
||||
colonyIds: readonly string[],
|
||||
held: ReadonlySet<string>,
|
||||
): Promise<Map<string, string>> {
|
||||
const days = new Map<string, string>();
|
||||
if (!held.has('inspections:read') || colonyIds.length === 0) {
|
||||
return days;
|
||||
}
|
||||
const groups = await this.prisma.inspection.groupBy({
|
||||
by: ['colonyId'],
|
||||
where: { colonyId: { in: [...colonyIds] } },
|
||||
_max: { date: true },
|
||||
});
|
||||
for (const group of groups) {
|
||||
if (group._max.date) {
|
||||
days.set(group.colonyId, berlinDayOf(group._max.date));
|
||||
}
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/** The youngest running withdrawal period per colony (treatments:read). */
|
||||
private async withdrawalUntil(
|
||||
colonyIds: readonly string[],
|
||||
held: ReadonlySet<string>,
|
||||
today: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const until = new Map<string, string>();
|
||||
if (!held.has('treatments:read') || colonyIds.length === 0) {
|
||||
return until;
|
||||
}
|
||||
const cutoff = dayToDate(addDays(today, -WITHDRAWAL_LOOKBACK_DAYS));
|
||||
const treatments = await this.prisma.treatment.findMany({
|
||||
where: {
|
||||
colonyId: { in: [...colonyIds] },
|
||||
withdrawalPeriodDays: { not: null },
|
||||
OR: [{ endDate: { gte: cutoff } }, { date: { gte: cutoff } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
colonyId: true,
|
||||
product: true,
|
||||
date: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
withdrawalPeriodDays: true,
|
||||
},
|
||||
});
|
||||
for (const treatment of treatments) {
|
||||
const window = withdrawalWindowOf(treatment);
|
||||
if (window === null || !isInWindow(window, today)) {
|
||||
continue;
|
||||
}
|
||||
const current = until.get(treatment.colonyId);
|
||||
if (current === undefined || window.untilDay > current) {
|
||||
until.set(treatment.colonyId, window.untilDay);
|
||||
}
|
||||
}
|
||||
return until;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open suggestions (visible ones, and only with `tasks:read`) and open
|
||||
* tasks (`tasks:read`) of the colonies, as rankable sources.
|
||||
*/
|
||||
private async stepSources(
|
||||
colonyIds: readonly string[],
|
||||
held: ReadonlySet<string>,
|
||||
today: string,
|
||||
): Promise<Map<string, StepSource[]>> {
|
||||
const sources = new Map<string, StepSource[]>();
|
||||
if (!held.has('tasks:read') || colonyIds.length === 0) {
|
||||
return sources;
|
||||
}
|
||||
const [suggestions, tasks] = await Promise.all([
|
||||
this.prisma.suggestion.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
this.suggestionViews.visibleWhere(held),
|
||||
{ status: 'OPEN', colonyId: { in: [...colonyIds] } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
ruleKey: true,
|
||||
reasonCode: true,
|
||||
evidence: true,
|
||||
intent: true,
|
||||
mode: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
dueDate: true,
|
||||
createdAt: true,
|
||||
colonyId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.task.findMany({
|
||||
where: {
|
||||
status: { in: [...OPEN_TASK_STATUSES] },
|
||||
relatedType: 'COLONY',
|
||||
relatedId: { in: [...colonyIds] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
dueDate: true,
|
||||
createdAt: true,
|
||||
relatedId: true,
|
||||
sourceAutomationTrigger: true,
|
||||
sourceSuggestion: { select: { intent: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const [lookup, templates] = await Promise.all([
|
||||
buildEvidenceLookup(
|
||||
this.prisma,
|
||||
suggestions.map((row) => row.evidence as SuggestionEvidence),
|
||||
),
|
||||
this.titleTemplates(suggestions.map((row) => row.ruleKey)),
|
||||
]);
|
||||
const add = (colonyId: string, source: StepSource): void => {
|
||||
sources.set(colonyId, [...(sources.get(colonyId) ?? []), source]);
|
||||
};
|
||||
// One open thing per intent and colony: an open task of the same intent
|
||||
// (accepted suggestion or legacy rule task) hides an open suggestion.
|
||||
const openTaskIntents = new Set(
|
||||
tasks.flatMap((row) => {
|
||||
const intent = taskIntentOf(row);
|
||||
return row.relatedId === null || intent === null
|
||||
? []
|
||||
: [`${row.relatedId}|${intent}`];
|
||||
}),
|
||||
);
|
||||
for (const row of suggestions) {
|
||||
if (
|
||||
row.colonyId === null ||
|
||||
openTaskIntents.has(`${row.colonyId}|${row.intent}`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const text = renderSuggestion(
|
||||
row,
|
||||
lookup,
|
||||
today,
|
||||
templates.get(row.ruleKey),
|
||||
);
|
||||
add(row.colonyId, {
|
||||
kind: 'SUGGESTION',
|
||||
id: row.id,
|
||||
intent: row.intent,
|
||||
mode: row.mode,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate),
|
||||
createdAt: row.createdAt,
|
||||
title: text.title,
|
||||
reasons: text.reasons,
|
||||
colonyId: row.colonyId,
|
||||
});
|
||||
}
|
||||
for (const row of tasks) {
|
||||
if (row.relatedId === null) {
|
||||
continue;
|
||||
}
|
||||
add(row.relatedId, {
|
||||
kind: 'TASK',
|
||||
id: row.id,
|
||||
intent: taskIntentOf(row),
|
||||
mode: null,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate),
|
||||
createdAt: row.createdAt,
|
||||
title: row.title,
|
||||
reasons: [],
|
||||
colonyId: row.relatedId,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** The `actionTemplate` of each rule replaces the title of its suggestions. */
|
||||
private async titleTemplates(
|
||||
ruleKeys: readonly AutomationTriggerType[],
|
||||
): Promise<Map<AutomationTriggerType, string | null>> {
|
||||
if (ruleKeys.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rules = await this.prisma.automationRule.findMany({
|
||||
where: { triggerType: { in: [...new Set(ruleKeys)] } },
|
||||
select: { triggerType: true, actionTemplate: true },
|
||||
});
|
||||
return new Map(
|
||||
rules.map((rule) => [rule.triggerType, rule.actionTemplate] as const),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user