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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user