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

@@ -95,16 +95,30 @@ describe('Follow-up controls (e2e)', () => {
},
data: { active: false, config: {} },
});
// Follow-up tasks are written without a user: remove their audit entry
// and TaskCreated (deliveries go with the event, ON DELETE CASCADE).
const followUpTaskIds = (
await prisma.task.findMany({
where: { relatedId: { in: colonyIds } },
// Follow-up suggestions are written without a user: remove them with
// their audit entries (deliveries go with the event, ON DELETE CASCADE).
const suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((task) => task.id);
).map((suggestion) => suggestion.id);
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: followUpTaskIds } },
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
...colonyIds.map((id) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: id },
})),
],
},
});
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.suggestion.deleteMany({
where: { id: { in: suggestionIds } },
});
const measureIds = [
...(await prisma.treatment.findMany({
@@ -122,9 +136,6 @@ describe('Follow-up controls (e2e)', () => {
await prisma.auditLog.deleteMany({
where: { entityId: { in: measureIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: followUpTaskIds } },
});
await app.close();
});
@@ -136,9 +147,9 @@ describe('Follow-up controls (e2e)', () => {
const colony = await newColony('OFF');
await treat(colony.id);
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
expect(
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
).toBe(0);
});
it('suggests a check due the configured days after the treatment ended', async () => {
@@ -155,25 +166,30 @@ describe('Follow-up controls (e2e)', () => {
endDate: '2026-08-10T00:00:00.000Z',
});
const tasks = await prisma.task.findMany({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
},
const suggestions = await prisma.suggestion.findMany({
where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' },
});
expect(tasks).toHaveLength(1);
expect(tasks[0].dueDate?.toISOString()).toBe('2026-08-24T00:00:00.000Z');
expect(tasks[0].description).toContain('Testmittel');
expect(tasks[0].status).toBe('OPEN');
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
status: 'OPEN',
intent: 'FOLLOW_UP',
reasonCode: 'TREATMENT_FOLLOW_UP',
requiredPermission: 'treatments:read',
evidence: { colonyId: colony.id, endedOn: '2026-08-10' },
});
expect(suggestions[0].dueDate?.toISOString()).toBe(
'2026-08-24T00:00:00.000Z',
);
// The rule proposes; no task exists before the suggestion is accepted.
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
// A second treatment while the check is still open adds nothing.
await treat(colony.id, { date: '2026-08-12T00:00:00.000Z' });
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
},
await prisma.suggestion.count({
where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' },
}),
).toBe(1);
});
@@ -199,17 +215,15 @@ describe('Follow-up controls (e2e)', () => {
})
.expect(201);
// Not before the FeedingRecorded handler ran.
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
expect(
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
).toBe(0);
await drainDomainEvents(app, [(response.body as { id: string }).id]);
const task = await prisma.task.findFirstOrThrow({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'FEEDING_FOLLOW_UP',
},
const suggestion = await prisma.suggestion.findFirstOrThrow({
where: { colonyId: colony.id, ruleKey: 'FEEDING_FOLLOW_UP' },
});
expect(task.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
expect(suggestion.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
expect(suggestion.evidence).toMatchObject({ fedOn: '2026-09-01' });
});
});