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

@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface BandBody {
yellowFrom: number | null;
@@ -41,6 +42,9 @@ describe('Varroa seasonal bands (e2e)', () => {
let apiaryId: string;
let productionColonyId: string;
let youngColonyId: string;
// Colonies of the rule cases: one per case, so no open suggestion of an
// earlier case keeps a later one from appearing.
const ruleColonyIds: string[] = [];
const get = (path: string) =>
request(app.getHttpServer())
@@ -80,7 +84,39 @@ describe('Varroa seasonal bands (e2e)', () => {
});
afterAll(async () => {
const colonyIds = [productionColonyId, youngColonyId];
const colonyIds = [productionColonyId, youngColonyId, ...ruleColonyIds];
const measurementIds = (
await prisma.varroaMeasurement.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((measurement) => measurement.id);
const suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((suggestion) => suggestion.id);
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
...colonyIds.map((id) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: id },
})),
],
},
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: measurementIds } },
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
@@ -137,6 +173,53 @@ describe('Varroa seasonal bands (e2e)', () => {
await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400);
});
describe('range form', () => {
interface SegmentsBody {
segments: {
from: string;
to: string;
bands: Record<string, unknown>;
postTreatmentUntil: string | null;
active: boolean;
}[];
}
const range = (query: string) =>
get(`/varroa-measurements/thresholds?${query}`);
it('returns merged segments split at the season edges', async () => {
const body = (
await range(
`colonyId=${productionColonyId}&from=2026-10-30&to=2026-11-02`,
).expect(200)
).body as SegmentsBody;
expect(body.segments.map((s) => [s.from, s.to, s.active])).toEqual([
['2026-10-30', '2026-10-31', true],
['2026-11-01', '2026-11-02', true],
]);
expect(body.segments[0].bands.NATURAL_MITE_FALL).toMatchObject({
reason: 'NATURAL_FALL_SEASON',
});
expect(body.segments[1].bands.NATURAL_MITE_FALL).toMatchObject({
reason: 'NATURAL_FALL_WINTER',
});
});
it('accepts 731 days and rejects 732', async () => {
await range('from=2025-01-01&to=2027-01-01').expect(200);
await range('from=2025-01-01&to=2027-01-02').expect(400);
});
it('rejects from after to, a missing bound, a mix with date and a bad day', async () => {
await range('from=2026-06-02&to=2026-06-01').expect(400);
await range('from=2026-06-02').expect(400);
await range('to=2026-06-02').expect(400);
await range('date=2026-06-01&from=2026-06-01&to=2026-06-02').expect(400);
await range('from=2026-02-30&to=2026-03-05').expect(400);
await range('colonyId=unknown&from=2026-06-01&to=2026-06-02').expect(404);
});
});
it('the old /threshold route is gone (404)', async () => {
await get('/varroa-measurements/threshold').expect(404);
});
@@ -221,4 +304,87 @@ describe('Varroa seasonal bands (e2e)', () => {
postTreatmentUntil: '2026-08-16',
});
});
describe('the varroa rule', () => {
const newColony = async (): Promise<string> => {
const colony = await prisma.colony.create({
data: {
code: `VS-R-${suffix}-${ruleColonyIds.length}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
ruleColonyIds.push(colony.id);
return colony.id;
};
/** Records a measurement and runs the handlers of its event. */
const measure = async (
colonyId: string,
date: string,
miteCount: number,
): Promise<string> => {
const created = (
await request(app.getHttpServer())
.post('/api/v1/varroa-measurements')
.set('Authorization', `Bearer ${token}`)
.send({
colonyId,
date,
method: 'NATURAL_MITE_FALL',
miteCount,
// One day on the board: the count is the rate per day.
durationMinutes: 24 * 60,
})
.expect(201)
).body as { id: string };
await drainDomainEvents(app, [created.id]);
return created.id;
};
const ruleSuggestions = (colonyId: string) =>
prisma.suggestion.findMany({
where: { colonyId, ruleKey: 'VARROA_THRESHOLD' },
});
it('12 mites/day in August proposes a treatment check', async () => {
const colonyId = await newColony();
const id = await measure(colonyId, '2026-08-10T00:00:00.000Z', 12);
const suggestions = await ruleSuggestions(colonyId);
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
intent: 'VARROA_TREAT',
reasonCode: 'VARROA_RED',
status: 'OPEN',
dedupeKey: `VARROA_THRESHOLD:${colonyId}:${id}`,
evidence: { value: 12, redAbove: 10, unit: 'MITES_PER_DAY' },
});
});
it('12 mites/day five days after treatment end proposes nothing', async () => {
const colonyId = await newColony();
await prisma.treatment.create({
data: {
colonyId,
userId,
date: new Date('2026-08-01T00:00:00.000Z'),
endDate: new Date('2026-08-05T00:00:00.000Z'),
product: 'Oxuvar',
},
});
await measure(colonyId, '2026-08-10T00:00:00.000Z', 12);
expect(await ruleSuggestions(colonyId)).toHaveLength(0);
});
it('10.0 mites/day in August proposes nothing (yellow)', async () => {
const colonyId = await newColony();
await measure(colonyId, '2026-08-10T00:00:00.000Z', 10);
expect(await ruleSuggestions(colonyId)).toHaveLength(0);
});
});
});