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

@@ -1,6 +1,8 @@
import { Logger } from '@nestjs/common';
import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
import type { AutopilotStep } from '../autopilot/autopilot.step';
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import type { TimeEventsStep } from './steps/time-events.step';
describe('DailyEvaluationJob', () => {
@@ -11,14 +13,29 @@ describe('DailyEvaluationJob', () => {
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
};
const AUTOPILOT_COUNTS = {
autopilotColonies: 4,
suggestionsOpened: 2,
suggestionsResolved: 1,
};
let timeEvents: { name: 'time-events'; run: jest.Mock };
let autopilot: { name: 'autopilot'; run: jest.Mock };
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
let job: DailyEvaluationJob;
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
timeEvents = { name: 'time-events', run: jest.fn() };
timeEvents.run.mockResolvedValue(COUNTS);
job = new DailyEvaluationJob(timeEvents as unknown as TimeEventsStep);
autopilot = { name: 'autopilot', run: jest.fn() };
autopilot.run.mockResolvedValue(AUTOPILOT_COUNTS);
expiry = { name: 'suggestions-expiry', run: jest.fn() };
expiry.run.mockResolvedValue({ suggestionsExpired: 3 });
job = new DailyEvaluationJob(
timeEvents as unknown as TimeEventsStep,
autopilot as unknown as AutopilotStep,
expiry as unknown as SuggestionsExpiryStep,
);
});
afterEach(() => {
@@ -31,8 +48,44 @@ describe('DailyEvaluationJob', () => {
});
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
expect(await job.run()).toEqual(COUNTS);
expect(await job.run()).toEqual({
...COUNTS,
...AUTOPILOT_COUNTS,
suggestionsExpired: 3,
});
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
});
it('runs time-events, autopilot and suggestions-expiry in that order', async () => {
await job.run();
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
autopilot.run.mock.invocationCallOrder[0],
);
expect(autopilot.run.mock.invocationCallOrder[0]).toBeLessThan(
expiry.run.mock.invocationCallOrder[0],
);
});
it('a failing autopilot step does not stop the expiry step', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
autopilot.run.mockRejectedValue(
new JobPartialFailure({ autopilotColonies: 1 }, ['1 colony']),
);
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(expiry.run).toHaveBeenCalled();
expect(failure).toMatchObject({
failedSteps: ['autopilot'],
result: { autopilotColonies: 1, suggestionsExpired: 3 },
message: 'Daily evaluation failed for: autopilot: 1 colony',
});
});
it('takes the Berlin day, not the UTC day, around midnight', async () => {
@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { AutopilotStep } from '../autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import { berlinToday } from '../common/dates';
import { JobPartialFailure } from '../system/job-run.service';
import {
@@ -21,11 +23,19 @@ export class DailyEvaluationJob implements Job {
readonly name: JobName = 'daily-evaluation';
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(private readonly timeEvents: TimeEventsStep) {}
constructor(
private readonly timeEvents: TimeEventsStep,
private readonly autopilot: AutopilotStep,
private readonly suggestionsExpiry: SuggestionsExpiryStep,
) {}
/** Evaluates the current Berlin day. */
async run(): Promise<JobResult> {
const steps: DailyEvaluationStep[] = [this.timeEvents];
const steps: DailyEvaluationStep[] = [
this.timeEvents,
this.autopilot,
this.suggestionsExpiry,
];
const { result, failed, failures } = await runDailyEvaluationSteps(
steps,
berlinToday(),
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
@@ -47,6 +48,7 @@ const JOB_CLASSES = [
BackupModule,
SystemModule,
SeasonModule,
AutopilotModule,
],
providers: [
...JOB_CLASSES,