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

@@ -0,0 +1,180 @@
import type { AutopilotRuleId } from '../automation/rule-config';
import { addDays, daysBetween } from '../common/dates';
import { inspectionDueOn } from '../season/inspection-due';
import { varroaMeasurementDue } from '../season/varroa-due';
import type {
AutopilotCandidate,
AutopilotConfigs,
AutopilotInput,
AutopilotRule,
} from './autopilot.types';
/**
* A3 only follows treatments that ended at most this many days ago, so old
* history without a later measurement does not produce suggestions.
*/
const RECHECK_MAX_AGE_DAYS = 60;
/** A1: an inspection is due (or nearly) and the colony is not in winter rest. */
const INSPECTION_LEAD_DAYS = 2;
const a1Inspection: AutopilotRule = (input, configs, today) => {
if (!configs.interval.active) {
return null;
}
const due = inspectionDueOn(
{
createdOn: input.colony.createdOn,
lastInspectedOn: input.lastInspection?.date ?? null,
},
input.season,
configs.interval.config,
);
if (due === null || due.dueOn > addDays(today, INSPECTION_LEAD_DAYS)) {
return null;
}
return {
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: due.firstOfYear ? 'INSPECTION_FIRST_OF_YEAR' : 'INSPECTION_DUE',
evidence: {
colonyId: input.colony.id,
inspectionId: input.lastInspection?.id ?? null,
lastInspectedOn: input.lastInspection?.date ?? null,
dueOn: due.dueOn,
intervalDays: due.intervalDays,
segment: input.season.segment,
},
dueDate: due.dueOn,
priority: due.dueOn < today ? 'HIGH' : 'NORMAL',
anchor: input.lastInspection?.id ?? 'none',
requiredPermission: 'inspections:read',
};
};
/** A2: the varroa measurement interval ran out, or the winter window is open. */
const a2VarroaMeasure: AutopilotRule = (input, configs, today) => {
const due = varroaMeasurementDue(
{
createdOn: input.colony.createdOn,
lastMeasuredOn: input.lastVarroa?.date ?? null,
lastMeasurementId: input.lastVarroa?.id ?? null,
},
input.season,
configs.autopilot.config,
today,
);
if (due === null || due.dueOn > today) {
return null;
}
return {
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode:
due.kind === 'WINTER_WINDOW'
? 'VARROA_WINTER_MEASURE'
: 'VARROA_MEASURE_DUE',
evidence: {
colonyId: input.colony.id,
measurementId: input.lastVarroa?.id ?? null,
lastMeasuredOn: input.lastVarroa?.date ?? null,
dueOn: due.dueOn,
intervalDays: configs.autopilot.config.varroaMeasureIntervalDays,
},
dueDate: due.dueOn,
priority: 'NORMAL',
anchor: due.anchor,
requiredPermission: 'varroa:read',
};
};
/** A3: a treatment ended long enough ago and nothing was measured since. */
const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
const treatment = input.lastTreatment;
if (
treatment === null ||
input.varroaSinceLastTreatmentEnd ||
daysBetween(treatment.endDay, today) > RECHECK_MAX_AGE_DAYS
) {
return null;
}
const recheckAfterDays = configs.varroa.config.recheckAfterDays;
const dueOn = addDays(treatment.endDay, recheckAfterDays);
if (dueOn > today) {
return null;
}
return {
rule: 'A3',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
evidence: {
colonyId: input.colony.id,
treatmentId: treatment.id,
endedOn: treatment.endDay,
recheckOn: dueOn,
},
dueDate: dueOn,
priority: 'NORMAL',
anchor: treatment.id,
// The action is a varroa measurement.
requiredPermission: 'varroa:read',
};
};
/** A4-A10 follow with Task 27. */
export const AUTOPILOT_RULES: Readonly<
Partial<Record<AutopilotRuleId, AutopilotRule>>
> = {
A1: a1Inspection,
A2: a2VarroaMeasure,
A3: a3VarroaRecheck,
};
const RULE_ORDER: readonly AutopilotRuleId[] = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
];
/**
* The candidates for one colony, always in the order A1...A10 so concurrent
* syncs take the proposal locks in the same order. A switched-off autopilot
* or rule yields nothing.
*/
export function evaluateAutopilot(
input: AutopilotInput,
configs: AutopilotConfigs,
today: string,
): AutopilotCandidate[] {
if (!configs.autopilot.active) {
return [];
}
const candidates: AutopilotCandidate[] = [];
for (const id of RULE_ORDER) {
const rule = AUTOPILOT_RULES[id];
if (rule === undefined || !configs.autopilot.config.rules[id]) {
continue;
}
const candidate = rule(input, configs, today);
if (candidate !== null) {
candidates.push(candidate);
}
}
// The recheck after a treatment is the measurement: A2 would ask twice.
return candidates.some((candidate) => candidate.rule === 'A3')
? candidates.filter((candidate) => candidate.rule !== 'A2')
: candidates;
}