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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 15:22:36 +02:00
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')