- Schema III: per-user notifications (broadcast fan-out migration, category, link, requiredPermissions filter), notification preferences and settings endpoints, calendar withdrawal switch, occupiedFrames - Suggestions API: list/summary with permission visibility and paging, accept/dismiss/reopen incl. bulk, SuggestionView on all responses - Varroa traffic light by season and method, young colonies, treatment effect window, sample-based percentage; shared withdrawal-window helper on Berlin calendar days; GET /varroa-measurements/thresholds - ColonySplit and QueenAssigned domain events with calendar and webhook handlers - Outbox claims compare against now() rounded to timestamp(3); fixes the flaky e2e claims of rows written with SQL now() Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
199 lines
5.2 KiB
TypeScript
199 lines
5.2 KiB
TypeScript
import type {
|
|
ColonyType,
|
|
VarroaMeasurement,
|
|
VarroaMethod,
|
|
} from '../../generated/prisma';
|
|
import { addDays, berlinDayOf } from '../common/dates';
|
|
import type { TreatmentWindowLite } from '../treatments/withdrawal-window';
|
|
import type { VarroaThresholdConfig } from './rule-config/rule-config.types';
|
|
|
|
export type VarroaLightKey = 'green' | 'yellow' | 'red';
|
|
|
|
export type VarroaBandReason =
|
|
| 'NATURAL_FALL_SEASON'
|
|
| 'NATURAL_FALL_WINTER'
|
|
| 'INFESTATION_SUMMER'
|
|
| 'INFESTATION_AUTUMN';
|
|
|
|
export interface VarroaBand {
|
|
method: VarroaMethod;
|
|
unit: 'MITES_PER_DAY' | 'PERCENT';
|
|
/** null: no yellow stage (winter). */
|
|
yellowFrom: number | null;
|
|
redAbove: number;
|
|
reason: VarroaBandReason;
|
|
youngColony: boolean;
|
|
}
|
|
|
|
export interface VarroaBands {
|
|
day: string;
|
|
bands: Partial<Record<VarroaMethod, VarroaBand>>;
|
|
/** Recheck day while a treatment effect is in force, otherwise null. */
|
|
postTreatmentUntil: string | null;
|
|
}
|
|
|
|
export interface VarroaRating {
|
|
light: VarroaLightKey | 'POST_TREATMENT' | null;
|
|
reasonCode: VarroaBandReason | 'POST_TREATMENT' | null;
|
|
band: VarroaBand | null;
|
|
postTreatmentUntil: string | null;
|
|
}
|
|
|
|
const PERCENT_METHODS: readonly VarroaMethod[] = [
|
|
'POWDERED_SUGAR',
|
|
'WASHING_METHOD',
|
|
];
|
|
|
|
function naturalFallBand(
|
|
month: number,
|
|
factor: number,
|
|
young: boolean,
|
|
config: VarroaThresholdConfig['naturalFall'],
|
|
): VarroaBand {
|
|
const inSeason = config.seasonMonths.includes(month);
|
|
return {
|
|
method: 'NATURAL_MITE_FALL',
|
|
unit: 'MITES_PER_DAY',
|
|
yellowFrom: inSeason ? config.yellowFrom * factor : null,
|
|
redAbove: (inSeason ? config.redAbove : config.winterRedAbove) * factor,
|
|
reason: inSeason ? 'NATURAL_FALL_SEASON' : 'NATURAL_FALL_WINTER',
|
|
youngColony: young,
|
|
};
|
|
}
|
|
|
|
/** The percentage band of the month, absent outside summer and autumn. */
|
|
function percentBand(
|
|
method: VarroaMethod,
|
|
month: number,
|
|
config: VarroaThresholdConfig['infestationPercent'],
|
|
): VarroaBand | undefined {
|
|
if (config.summerMonths.includes(month)) {
|
|
return {
|
|
method,
|
|
unit: 'PERCENT',
|
|
...config.summer,
|
|
reason: 'INFESTATION_SUMMER',
|
|
youngColony: false,
|
|
};
|
|
}
|
|
if (config.autumnMonths.includes(month)) {
|
|
return {
|
|
method,
|
|
unit: 'PERCENT',
|
|
...config.autumn,
|
|
reason: 'INFESTATION_AUTUMN',
|
|
youngColony: false,
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Latest recheck day of the treatments whose effect covers `day`. */
|
|
function postTreatmentUntil(
|
|
day: string,
|
|
recheckAfterDays: number,
|
|
treatments: readonly TreatmentWindowLite[],
|
|
): string | null {
|
|
let until: string | null = null;
|
|
for (const { startDay, endDay } of treatments) {
|
|
const recheckDay = addDays(endDay, recheckAfterDays);
|
|
const covers = startDay <= day && day < recheckDay;
|
|
if (covers && (until === null || recheckDay > until)) {
|
|
until = recheckDay;
|
|
}
|
|
}
|
|
return until;
|
|
}
|
|
|
|
/**
|
|
* The bands in force on `day` for a colony of `colonyType`: natural mite fall
|
|
* by season (young colonies at the reduced factor), the percentage methods in
|
|
* summer and autumn only, no band for the drone brood check.
|
|
*/
|
|
export function bandsFor(
|
|
day: string,
|
|
colonyType: ColonyType | null,
|
|
config: VarroaThresholdConfig,
|
|
treatments: readonly TreatmentWindowLite[],
|
|
): VarroaBands {
|
|
const month = Number(day.slice(5, 7));
|
|
const young =
|
|
colonyType !== null && config.youngColonyTypes.includes(colonyType);
|
|
const factor = young ? config.youngColonyFactor : 1;
|
|
const bands: VarroaBands['bands'] = {
|
|
NATURAL_MITE_FALL: naturalFallBand(
|
|
month,
|
|
factor,
|
|
young,
|
|
config.naturalFall,
|
|
),
|
|
};
|
|
for (const method of PERCENT_METHODS) {
|
|
const band = percentBand(method, month, config.infestationPercent);
|
|
if (band) {
|
|
bands[method] = band;
|
|
}
|
|
}
|
|
return {
|
|
day,
|
|
bands,
|
|
postTreatmentUntil: postTreatmentUntil(
|
|
day,
|
|
config.recheckAfterDays,
|
|
treatments,
|
|
),
|
|
};
|
|
}
|
|
|
|
/** Red above redAbove, yellow from yellowFrom, otherwise green. */
|
|
export function classify(
|
|
value: number | null,
|
|
band: VarroaBand | null | undefined,
|
|
): VarroaLightKey | null {
|
|
if (value === null || !band) {
|
|
return null;
|
|
}
|
|
if (value > band.redAbove) {
|
|
return 'red';
|
|
}
|
|
if (band.yellowFrom !== null && value >= band.yellowFrom) {
|
|
return 'yellow';
|
|
}
|
|
return 'green';
|
|
}
|
|
|
|
/**
|
|
* The light of one measurement. A measurement taken from the start of a
|
|
* treatment until the recheck day has no light (treatment effect), except the
|
|
* drone brood check, which never has one.
|
|
*/
|
|
export function rateMeasurement(
|
|
m: Pick<VarroaMeasurement, 'date' | 'method' | 'calculatedValue'>,
|
|
colonyType: ColonyType | null,
|
|
config: VarroaThresholdConfig,
|
|
treatments: readonly TreatmentWindowLite[],
|
|
): VarroaRating {
|
|
const day = berlinDayOf(m.date);
|
|
const { bands, postTreatmentUntil: recheckDay } = bandsFor(
|
|
day,
|
|
colonyType,
|
|
config,
|
|
treatments,
|
|
);
|
|
const band = bands[m.method] ?? null;
|
|
if (recheckDay !== null && m.method !== 'DRONE_BROOD_CONTROL') {
|
|
return {
|
|
light: 'POST_TREATMENT',
|
|
reasonCode: 'POST_TREATMENT',
|
|
band: null,
|
|
postTreatmentUntil: recheckDay,
|
|
};
|
|
}
|
|
return {
|
|
light: classify(m.calculatedValue, band),
|
|
reasonCode: band?.reason ?? null,
|
|
band,
|
|
postTreatmentUntil: null,
|
|
};
|
|
}
|