feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -26,4 +26,23 @@ export class VarroaThresholdsQuery {
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' })
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Range form: first day (JJJJ-MM-TT); needs `to`, excludes `date`',
|
||||
example: '2025-01-01',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN, { message: 'from muss ein Tag (JJJJ-MM-TT) sein' })
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Range form: last day (JJJJ-MM-TT), at most 731 days',
|
||||
example: '2026-12-31',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN, { message: 'to muss ein Tag (JJJJ-MM-TT) sein' })
|
||||
to?: string;
|
||||
}
|
||||
@@ -100,6 +100,108 @@ describe('VarroaBandsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('thresholdSegments', () => {
|
||||
const range = (from: string, to: string, colonyId?: string) =>
|
||||
service.thresholdSegments({ colonyId, from, to });
|
||||
|
||||
it('merges days with identical bands into one segment', async () => {
|
||||
const { segments } = await range('2026-06-05', '2026-06-20');
|
||||
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0]).toMatchObject({
|
||||
from: '2026-06-05',
|
||||
to: '2026-06-20',
|
||||
active: true,
|
||||
postTreatmentUntil: null,
|
||||
});
|
||||
expect(segments[0].bands.NATURAL_MITE_FALL).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2026-02-27', '2026-03-02', '2026-02-28', '2026-03-01'],
|
||||
['2026-04-29', '2026-05-02', '2026-04-30', '2026-05-01'],
|
||||
['2026-07-30', '2026-08-02', '2026-07-31', '2026-08-01'],
|
||||
['2026-10-30', '2026-11-02', '2026-10-31', '2026-11-01'],
|
||||
])(
|
||||
'splits %s..%s between %s and %s',
|
||||
async (from, to, lastOfFirst, firstOfSecond) => {
|
||||
const { segments } = await range(from, to);
|
||||
|
||||
expect(segments.map((segment) => [segment.from, segment.to])).toEqual([
|
||||
[from, lastOfFirst],
|
||||
[firstOfSecond, to],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('splits at treatment windows and names the recheck day', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
});
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{ date: d('2026-07-12'), startDate: d('2026-07-10'), endDate: null },
|
||||
]);
|
||||
|
||||
const { segments } = await range('2026-07-08', '2026-07-30', 'c1');
|
||||
|
||||
expect(segments.map((s) => [s.from, s.to, s.postTreatmentUntil])).toEqual(
|
||||
[
|
||||
['2026-07-08', '2026-07-09', null],
|
||||
['2026-07-10', '2026-07-25', '2026-07-26'],
|
||||
['2026-07-26', '2026-07-30', null],
|
||||
],
|
||||
);
|
||||
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.colony.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns one inactive segment without bands while the rule is off', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'r',
|
||||
active: false,
|
||||
config: {},
|
||||
actionTemplate: null,
|
||||
});
|
||||
|
||||
await expect(range('2026-03-01', '2026-09-30')).resolves.toEqual({
|
||||
segments: [
|
||||
{
|
||||
from: '2026-03-01',
|
||||
to: '2026-09-30',
|
||||
bands: {},
|
||||
postTreatmentUntil: null,
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts exactly 731 days and rejects 732', async () => {
|
||||
await expect(range('2025-01-01', '2027-01-01')).resolves.toBeDefined();
|
||||
await expect(range('2025-01-01', '2027-01-02')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects from after to, invalid days and an unknown colony', async () => {
|
||||
await expect(range('2026-06-02', '2026-06-01')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(range('2026-02-30', '2026-03-05')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
range('2026-06-01', '2026-06-02', 'missing'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('accepts a single day', async () => {
|
||||
const { segments } = await range('2026-06-01', '2026-06-01');
|
||||
expect(segments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate', () => {
|
||||
const measurement = {
|
||||
id: 'm1',
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type VarroaBands,
|
||||
type VarroaRating,
|
||||
} from '../automation/varroa-bands';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { addDays, berlinToday, dayToDate, daysBetween } from '../common/dates';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
||||
|
||||
@@ -20,6 +20,22 @@ export interface VarroaThresholds extends VarroaBands {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** A run of consecutive days on which the bands are identical. */
|
||||
export interface VarroaThresholdSegment {
|
||||
from: string;
|
||||
to: string;
|
||||
bands: VarroaBands['bands'];
|
||||
postTreatmentUntil: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface VarroaThresholdSegments {
|
||||
segments: VarroaThresholdSegment[];
|
||||
}
|
||||
|
||||
/** Longest range of the range query, in days (two years plus a leap day). */
|
||||
export const MAX_THRESHOLD_RANGE_DAYS = 731;
|
||||
|
||||
type RatableMeasurement = Pick<
|
||||
VarroaMeasurement,
|
||||
'date' | 'method' | 'calculatedValue'
|
||||
@@ -72,6 +88,68 @@ export class VarroaBandsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bands over a day range as segments of identical bands (they change by
|
||||
* month and with treatment windows). Rule, colony type and treatments are
|
||||
* read once.
|
||||
*/
|
||||
async thresholdSegments(query: {
|
||||
colonyId?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<VarroaThresholdSegments> {
|
||||
const from = validDay(query.from);
|
||||
const to = validDay(query.to);
|
||||
const days = daysBetween(from, to) + 1;
|
||||
if (days < 1 || days > MAX_THRESHOLD_RANGE_DAYS) {
|
||||
throw new BadRequestException(
|
||||
`Der Zeitraum muss zwischen 1 und ${MAX_THRESHOLD_RANGE_DAYS} Tage lang sein.`,
|
||||
);
|
||||
}
|
||||
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
|
||||
const colonyType =
|
||||
query.colonyId === undefined
|
||||
? null
|
||||
: await this.colonyTypeOf(query.colonyId);
|
||||
if (!rule.active) {
|
||||
return {
|
||||
segments: [
|
||||
{ from, to, bands: {}, postTreatmentUntil: null, active: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
const treatments =
|
||||
query.colonyId === undefined
|
||||
? []
|
||||
: await treatmentWindowsIn(this.prisma, query.colonyId);
|
||||
const segments: VarroaThresholdSegment[] = [];
|
||||
let key = '';
|
||||
for (let offset = 0; offset < days; offset++) {
|
||||
const day = addDays(from, offset);
|
||||
const { bands, postTreatmentUntil } = bandsFor(
|
||||
day,
|
||||
colonyType,
|
||||
rule.config,
|
||||
treatments,
|
||||
);
|
||||
const dayKey = JSON.stringify([bands, postTreatmentUntil]);
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && dayKey === key) {
|
||||
last.to = day;
|
||||
} else {
|
||||
segments.push({
|
||||
from: day,
|
||||
to: day,
|
||||
bands,
|
||||
postTreatmentUntil,
|
||||
active: true,
|
||||
});
|
||||
key = dayKey;
|
||||
}
|
||||
}
|
||||
return { segments };
|
||||
}
|
||||
|
||||
/** Each measurement of one colony with its rating. */
|
||||
async rate<M extends RatableMeasurement>(
|
||||
colonyId: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -34,11 +35,20 @@ export class VarroaController {
|
||||
return this.varroaService.findAll();
|
||||
}
|
||||
|
||||
/** Bands in force on a day for a colony; declared before :id so it is not shadowed. */
|
||||
/** Bands in force on a day (`date`) or as segments over a range (`from`, `to`) for a colony; declared before :id so it is not shadowed. */
|
||||
@RequirePermission('varroa:read', 'varroa:create')
|
||||
@Get('thresholds')
|
||||
getThresholds(@Query() query: VarroaThresholdsQuery) {
|
||||
return this.varroaBandsService.thresholds(query);
|
||||
const { colonyId, date, from, to } = query;
|
||||
if (from === undefined && to === undefined) {
|
||||
return this.varroaBandsService.thresholds({ colonyId, date });
|
||||
}
|
||||
if (from === undefined || to === undefined || date !== undefined) {
|
||||
throw new BadRequestException(
|
||||
'Bitte entweder date oder from und to angeben.',
|
||||
);
|
||||
}
|
||||
return this.varroaBandsService.thresholdSegments({ colonyId, from, to });
|
||||
}
|
||||
|
||||
@RequirePermission('varroa:read')
|
||||
|
||||
Reference in new issue
Block a user