- 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]>
176 lines
4.6 KiB
TypeScript
176 lines
4.6 KiB
TypeScript
import { daysBetween } from '../../common/dates';
|
|
import type { SuggestionEvidence } from '../suggestion.types';
|
|
import {
|
|
formatDayDe,
|
|
type SuggestionTextRegister,
|
|
type SuggestionTextTemplate,
|
|
} from '../suggestion-texts';
|
|
|
|
const MAX_PRODUCT_LENGTH = 40;
|
|
|
|
const SEGMENT_PHRASES: Readonly<Record<string, string>> = {
|
|
FRUEHJAHR: 'Im Frühjahr',
|
|
SCHWARMZEIT: 'In der Schwarmzeit',
|
|
SOMMER: 'Im Sommer',
|
|
SPAETSOMMER: 'Im Spätsommer',
|
|
HERBST: 'Im Herbst',
|
|
};
|
|
|
|
function text(evidence: SuggestionEvidence, key: string): string | null {
|
|
const value = evidence[key];
|
|
return typeof value === 'string' ? value : null;
|
|
}
|
|
|
|
function count(evidence: SuggestionEvidence, key: string): number | null {
|
|
const value = evidence[key];
|
|
return typeof value === 'number' ? value : null;
|
|
}
|
|
|
|
/** 'vor 8 Tagen', 'vor 1 Tag', 'heute'. */
|
|
function agoDe(from: string, today: string): string {
|
|
const days = Math.max(0, daysBetween(from, today));
|
|
if (days === 0) {
|
|
return 'heute';
|
|
}
|
|
return `vor ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
|
|
}
|
|
|
|
function lastInspectionReason(
|
|
evidence: SuggestionEvidence,
|
|
today: string,
|
|
): string {
|
|
const last = text(evidence, 'lastInspectedOn');
|
|
return last === null
|
|
? 'Noch keine Durchsicht erfasst.'
|
|
: `Letzte Durchsicht ${agoDe(last, today)}.`;
|
|
}
|
|
|
|
function intervalReason(evidence: SuggestionEvidence): string[] {
|
|
const phrase = SEGMENT_PHRASES[text(evidence, 'segment') ?? ''];
|
|
const days = count(evidence, 'intervalDays');
|
|
return phrase !== undefined && days !== null
|
|
? [`${phrase} alle ${days} Tage.`]
|
|
: [];
|
|
}
|
|
|
|
const INSPECTION_DUE: SuggestionTextTemplate = {
|
|
ruleKey: 'INSPECTION_INTERVAL',
|
|
example: {
|
|
colonyId: 'c1',
|
|
inspectionId: 'i1',
|
|
lastInspectedOn: '2026-05-04',
|
|
dueOn: '2026-05-11',
|
|
intervalDays: 7,
|
|
segment: 'SCHWARMZEIT',
|
|
},
|
|
render: (evidence, _lookup, today) => ({
|
|
title: 'Durchsicht',
|
|
reasons: [
|
|
lastInspectionReason(evidence, today),
|
|
...intervalReason(evidence),
|
|
],
|
|
}),
|
|
};
|
|
|
|
const INSPECTION_FIRST_OF_YEAR: SuggestionTextTemplate = {
|
|
ruleKey: 'INSPECTION_INTERVAL',
|
|
example: {
|
|
colonyId: 'c1',
|
|
inspectionId: 'i1',
|
|
lastInspectedOn: '2025-09-20',
|
|
dueOn: '2026-03-20',
|
|
intervalDays: 14,
|
|
segment: 'FRUEHJAHR',
|
|
},
|
|
render: (evidence) => {
|
|
const last = text(evidence, 'lastInspectedOn');
|
|
return {
|
|
title: 'Erste Durchsicht im Jahr',
|
|
reasons: [
|
|
'Die Saison hat begonnen.',
|
|
last === null
|
|
? 'Noch keine Durchsicht erfasst.'
|
|
: `Letzte Durchsicht am ${formatDayDe(last, true)}.`,
|
|
],
|
|
};
|
|
},
|
|
};
|
|
|
|
const VARROA_MEASURE_DUE: SuggestionTextTemplate = {
|
|
ruleKey: 'SEASON_AUTOPILOT',
|
|
example: {
|
|
colonyId: 'c1',
|
|
measurementId: 'm1',
|
|
lastMeasuredOn: '2026-05-06',
|
|
dueOn: '2026-06-03',
|
|
intervalDays: 28,
|
|
},
|
|
render: (evidence, _lookup, today) => {
|
|
const last = text(evidence, 'lastMeasuredOn');
|
|
const days = count(evidence, 'intervalDays');
|
|
return {
|
|
title: 'Varroa messen',
|
|
reasons: [
|
|
last === null
|
|
? 'Noch keine Messung erfasst.'
|
|
: `Letzte Messung ${agoDe(last, today)}.`,
|
|
...(days === null ? [] : [`Alle ${days} Tage messen.`]),
|
|
],
|
|
};
|
|
},
|
|
};
|
|
|
|
const VARROA_WINTER_MEASURE: SuggestionTextTemplate = {
|
|
ruleKey: 'SEASON_AUTOPILOT',
|
|
example: {
|
|
colonyId: 'c1',
|
|
measurementId: 'm1',
|
|
lastMeasuredOn: '2026-09-01',
|
|
dueOn: '2026-11-15',
|
|
intervalDays: 28,
|
|
},
|
|
render: () => ({
|
|
title: 'Varroa messen',
|
|
reasons: ['Vor der Winterbehandlung den Milbenfall messen.'],
|
|
}),
|
|
};
|
|
|
|
const VARROA_RECHECK_DUE: SuggestionTextTemplate = {
|
|
ruleKey: 'SEASON_AUTOPILOT',
|
|
example: {
|
|
colonyId: 'c1',
|
|
treatmentId: 't1',
|
|
endedOn: '2026-08-02',
|
|
recheckOn: '2026-08-16',
|
|
},
|
|
render: (evidence, lookup) => {
|
|
const treatmentId = text(evidence, 'treatmentId');
|
|
const product =
|
|
(treatmentId === null ? null : lookup.treatmentProduct(treatmentId)) ??
|
|
'Behandlung';
|
|
const name = product.slice(0, MAX_PRODUCT_LENGTH);
|
|
const ended = text(evidence, 'endedOn');
|
|
const recheck = text(evidence, 'recheckOn');
|
|
return {
|
|
title: 'Behandlungserfolg messen',
|
|
reasons: [
|
|
ended === null
|
|
? `Behandlung mit ${name} beendet.`
|
|
: `Behandlung mit ${name} endete am ${formatDayDe(ended)}`,
|
|
...(recheck === null
|
|
? []
|
|
: [`Nachkontrolle ab ${formatDayDe(recheck)}`]),
|
|
],
|
|
};
|
|
},
|
|
};
|
|
|
|
/** Texts of the season autopilot suggestions, by reason code. */
|
|
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {
|
|
INSPECTION_DUE,
|
|
INSPECTION_FIRST_OF_YEAR,
|
|
VARROA_MEASURE_DUE,
|
|
VARROA_WINTER_MEASURE,
|
|
VARROA_RECHECK_DUE,
|
|
};
|