feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- 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]>
This commit is contained in:
1 parent
d99a697739
commit
f468dc5f75
87 files changed
+6022
-510
No files matched your search
@@ -45,7 +45,7 @@ describe('AutomationService', () => {
|
||||
$connect: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let notifications: { record: jest.Mock };
|
||||
let notifications: { recordForPermission: jest.Mock };
|
||||
let tasks: { createInTx: jest.Mock };
|
||||
|
||||
const queenlessRule: Rule = {
|
||||
@@ -102,7 +102,9 @@ describe('AutomationService', () => {
|
||||
$connect: jest.fn(),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
notifications = { record: jest.fn().mockResolvedValue({ id: 'n-1' }) };
|
||||
notifications = {
|
||||
recordForPermission: jest.fn().mockResolvedValue([{ id: 'n-1' }]),
|
||||
};
|
||||
tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
|
||||
service = new AutomationService(
|
||||
prisma as unknown as PrismaService,
|
||||
@@ -258,7 +260,9 @@ describe('AutomationService', () => {
|
||||
staged.push(client === tx ? 'task' : 'task outside tx');
|
||||
return Promise.resolve({ id: 'task-1' });
|
||||
});
|
||||
notifications.record.mockRejectedValue(new Error('notifications down'));
|
||||
notifications.recordForPermission.mockRejectedValue(
|
||||
new Error('notifications down'),
|
||||
);
|
||||
auditLog.record.mockImplementation((_entry: unknown, client: unknown) => {
|
||||
staged.push(client === tx ? 'audit' : 'audit outside tx');
|
||||
return Promise.resolve();
|
||||
@@ -303,9 +307,11 @@ describe('AutomationService', () => {
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(notifications.record).toHaveBeenCalledWith(
|
||||
expect(notifications.recordForPermission).toHaveBeenCalledWith(
|
||||
'tasks:read',
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'SUGGESTION',
|
||||
message: 'Kontrolle empfohlen',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: 'colony-1',
|
||||
@@ -332,7 +338,9 @@ describe('AutomationService', () => {
|
||||
});
|
||||
|
||||
it('lets a failed notification write propagate, so the delivery is rolled back and retried', async () => {
|
||||
notifications.record.mockRejectedValue(new Error('notifications down'));
|
||||
notifications.recordForPermission.mockRejectedValue(
|
||||
new Error('notifications down'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.evaluateInspectionIn(asTx(), queenlessInspection as never),
|
||||
@@ -355,7 +363,7 @@ describe('AutomationService', () => {
|
||||
select: { id: true },
|
||||
});
|
||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||
expect(notifications.record).not.toHaveBeenCalled();
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -403,7 +411,7 @@ describe('AutomationService', () => {
|
||||
await expect(
|
||||
service.evaluateInspectionIn(asTx(), queenlessInspection as never),
|
||||
).rejects.toThrow('tasks down');
|
||||
expect(notifications.record).not.toHaveBeenCalled();
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -426,7 +434,7 @@ describe('AutomationService', () => {
|
||||
await service.evaluateInspectionIn(asTx(), queenlessInspection as never);
|
||||
|
||||
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.record).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not trigger when the queen was seen', async () => {
|
||||
|
||||
@@ -600,8 +600,16 @@ export class AutomationService {
|
||||
},
|
||||
null,
|
||||
);
|
||||
await this.notifications.record(
|
||||
{ priority: 'WARNUNG', message: title, relatedType, relatedId },
|
||||
// Task 7 replaces this path by suggestions.
|
||||
await this.notifications.recordForPermission(
|
||||
'tasks:read',
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'SUGGESTION',
|
||||
message: title,
|
||||
relatedType,
|
||||
relatedId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.auditLog.record(
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { ColonyType, VarroaMethod } from '../../generated/prisma';
|
||||
import { bandsFor, classify, rateMeasurement } from './varroa-bands';
|
||||
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
|
||||
|
||||
const DEFAULTS = DEFAULT_VARROA_THRESHOLD_CONFIG;
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
describe('varroa bands', () => {
|
||||
it.each<
|
||||
[
|
||||
string,
|
||||
ColonyType,
|
||||
VarroaMethod,
|
||||
number,
|
||||
'green' | 'yellow' | 'red' | null,
|
||||
]
|
||||
>([
|
||||
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.0, 'yellow'],
|
||||
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.1, 'red'],
|
||||
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 4.9, 'green'],
|
||||
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 5, 'yellow'],
|
||||
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.6, 'red'],
|
||||
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.5, 'green'],
|
||||
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 5.1, 'red'],
|
||||
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 2.5, 'yellow'],
|
||||
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.3, 'red'],
|
||||
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.25, 'green'],
|
||||
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 5.1, 'red'],
|
||||
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 1, 'yellow'],
|
||||
['2026-07-10', 'YOUNG_COLONY', 'POWDERED_SUGAR', 4, 'yellow'],
|
||||
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 1.5, 'green'],
|
||||
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 2, 'yellow'],
|
||||
['2026-04-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 9, null],
|
||||
['2026-11-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 9, null],
|
||||
['2026-07-10', 'PRODUCTION_COLONY', 'DRONE_BROOD_CONTROL', 50, null],
|
||||
])('%s %s %s %p -> %p', (day, type, method, value, light) =>
|
||||
expect(
|
||||
classify(value, bandsFor(day, type, DEFAULTS, []).bands[method]),
|
||||
).toBe(light),
|
||||
);
|
||||
|
||||
it('classifies a missing value or band as no light', () => {
|
||||
const band = bandsFor('2026-08-10', null, DEFAULTS, []).bands
|
||||
.NATURAL_MITE_FALL;
|
||||
expect(classify(null, band)).toBeNull();
|
||||
expect(classify(3, undefined)).toBeNull();
|
||||
expect(classify(3, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('describes the band with its reason, unit and colony kind', () => {
|
||||
const autumn = bandsFor('2026-08-10', 'YOUNG_COLONY', DEFAULTS, []);
|
||||
expect(autumn.day).toBe('2026-08-10');
|
||||
expect(autumn.bands.NATURAL_MITE_FALL).toEqual({
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
unit: 'MITES_PER_DAY',
|
||||
yellowFrom: 2.5,
|
||||
redAbove: 5,
|
||||
reason: 'NATURAL_FALL_SEASON',
|
||||
youngColony: true,
|
||||
});
|
||||
expect(autumn.bands.POWDERED_SUGAR).toMatchObject({
|
||||
unit: 'PERCENT',
|
||||
yellowFrom: 2,
|
||||
redAbove: 5,
|
||||
reason: 'INFESTATION_AUTUMN',
|
||||
});
|
||||
const winter = bandsFor('2026-01-10', 'PRODUCTION_COLONY', DEFAULTS, []);
|
||||
expect(winter.bands.NATURAL_MITE_FALL).toMatchObject({
|
||||
yellowFrom: null,
|
||||
redAbove: 0.5,
|
||||
reason: 'NATURAL_FALL_WINTER',
|
||||
youngColony: false,
|
||||
});
|
||||
expect(winter.bands.POWDERED_SUGAR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats a colony without a type as a full colony', () =>
|
||||
expect(
|
||||
bandsFor('2026-08-10', null, DEFAULTS, []).bands.NATURAL_MITE_FALL,
|
||||
).toMatchObject({ redAbove: 10, youngColony: false }));
|
||||
|
||||
it('names the recheck day while a treatment effect is in force', () => {
|
||||
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
|
||||
const until = (day: string) =>
|
||||
bandsFor(day, null, DEFAULTS, treatments).postTreatmentUntil;
|
||||
expect(until('2026-07-25')).toBe('2026-08-16');
|
||||
expect(until('2026-08-15')).toBe('2026-08-16');
|
||||
expect(until('2026-08-16')).toBeNull();
|
||||
expect(until('2026-07-19')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the latest recheck day of overlapping treatments', () =>
|
||||
expect(
|
||||
bandsFor('2026-08-01', null, DEFAULTS, [
|
||||
{ startDay: '2026-07-01', endDay: '2026-07-20' },
|
||||
{ startDay: '2026-07-25', endDay: '2026-08-10' },
|
||||
]).postTreatmentUntil,
|
||||
).toBe('2026-08-24'));
|
||||
|
||||
describe('month edges', () => {
|
||||
const lightOn = (
|
||||
day: string,
|
||||
method: VarroaMethod,
|
||||
value: number,
|
||||
): string | null =>
|
||||
classify(
|
||||
value,
|
||||
bandsFor(day, 'PRODUCTION_COLONY', DEFAULTS, []).bands[method],
|
||||
);
|
||||
|
||||
it.each<[string, VarroaMethod, number, string | null]>([
|
||||
['2026-02-28', 'NATURAL_MITE_FALL', 3, 'red'],
|
||||
['2026-03-01', 'NATURAL_MITE_FALL', 3, 'green'],
|
||||
['2026-10-31', 'NATURAL_MITE_FALL', 3, 'green'],
|
||||
['2026-11-01', 'NATURAL_MITE_FALL', 3, 'red'],
|
||||
['2026-04-30', 'POWDERED_SUGAR', 3, null],
|
||||
['2026-05-01', 'POWDERED_SUGAR', 3, 'yellow'],
|
||||
['2026-07-31', 'POWDERED_SUGAR', 1.5, 'yellow'],
|
||||
['2026-08-01', 'POWDERED_SUGAR', 1.5, 'green'],
|
||||
['2026-10-31', 'POWDERED_SUGAR', 3, 'yellow'],
|
||||
['2026-11-01', 'POWDERED_SUGAR', 3, null],
|
||||
['2026-04-30', 'WASHING_METHOD', 3, null],
|
||||
['2026-05-01', 'WASHING_METHOD', 3, 'yellow'],
|
||||
['2026-07-31', 'WASHING_METHOD', 1.5, 'yellow'],
|
||||
['2026-08-01', 'WASHING_METHOD', 1.5, 'green'],
|
||||
['2026-10-31', 'WASHING_METHOD', 3, 'yellow'],
|
||||
['2026-11-01', 'WASHING_METHOD', 3, null],
|
||||
])('%s %s %p -> %p', (day, method, value, light) =>
|
||||
expect(lightOn(day, method, value)).toBe(light),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the young factor off the percent bands', () => {
|
||||
const { bands } = bandsFor('2026-07-10', 'YOUNG_COLONY', DEFAULTS, []);
|
||||
expect(bands.POWDERED_SUGAR).toMatchObject({
|
||||
youngColony: false,
|
||||
yellowFrom: 1,
|
||||
redAbove: 5,
|
||||
});
|
||||
expect(bands.NATURAL_MITE_FALL?.youngColony).toBe(true);
|
||||
});
|
||||
|
||||
describe('treatment effect edges', () => {
|
||||
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
|
||||
const ratingOn = (day: string, method: VarroaMethod) =>
|
||||
rateMeasurement(
|
||||
{ date: d(day), method, calculatedValue: 12 },
|
||||
null,
|
||||
DEFAULTS,
|
||||
treatments,
|
||||
);
|
||||
|
||||
it('starts on the start day and ends the day before the recheck day', () => {
|
||||
expect(ratingOn('2026-07-19', 'NATURAL_MITE_FALL').light).toBe('red');
|
||||
expect(ratingOn('2026-07-20', 'NATURAL_MITE_FALL').light).toBe(
|
||||
'POST_TREATMENT',
|
||||
);
|
||||
expect(ratingOn('2026-08-15', 'NATURAL_MITE_FALL').light).toBe(
|
||||
'POST_TREATMENT',
|
||||
);
|
||||
expect(ratingOn('2026-08-16', 'NATURAL_MITE_FALL').light).toBe('red');
|
||||
});
|
||||
|
||||
it('covers a single-day treatment', () => {
|
||||
const single = [{ startDay: '2026-08-01', endDay: '2026-08-01' }];
|
||||
const until = (day: string) =>
|
||||
bandsFor(day, null, DEFAULTS, single).postTreatmentUntil;
|
||||
expect(until('2026-08-01')).toBe('2026-08-15');
|
||||
expect(until('2026-08-14')).toBe('2026-08-15');
|
||||
expect(until('2026-08-15')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies to the percent methods and never to the drone brood check', () => {
|
||||
expect(ratingOn('2026-07-25', 'POWDERED_SUGAR').light).toBe(
|
||||
'POST_TREATMENT',
|
||||
);
|
||||
expect(ratingOn('2026-08-05', 'WASHING_METHOD').light).toBe(
|
||||
'POST_TREATMENT',
|
||||
);
|
||||
expect(ratingOn('2026-07-25', 'DRONE_BROOD_CONTROL').light).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('rates by the Berlin calendar day of the measurement', () => {
|
||||
// 2026-10-31T23:30Z is 2026-11-01 00:30 in Berlin: winter band.
|
||||
const rating = rateMeasurement(
|
||||
{
|
||||
date: new Date('2026-11-01T00:30:00+01:00'),
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 3,
|
||||
},
|
||||
null,
|
||||
DEFAULTS,
|
||||
[],
|
||||
);
|
||||
expect(rating.reasonCode).toBe('NATURAL_FALL_WINTER');
|
||||
expect(rating.light).toBe('red');
|
||||
});
|
||||
|
||||
describe('rateMeasurement', () => {
|
||||
it('a measurement 5 days after treatment end has no light and names the recheck day', () =>
|
||||
expect(
|
||||
rateMeasurement(
|
||||
{
|
||||
date: d('2026-08-07'),
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 12,
|
||||
},
|
||||
'PRODUCTION_COLONY',
|
||||
DEFAULTS,
|
||||
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
|
||||
),
|
||||
).toEqual({
|
||||
light: 'POST_TREATMENT',
|
||||
reasonCode: 'POST_TREATMENT',
|
||||
band: null,
|
||||
postTreatmentUntil: '2026-08-16',
|
||||
}));
|
||||
|
||||
it('rates outside a treatment effect with the band and reason', () => {
|
||||
const rating = rateMeasurement(
|
||||
{
|
||||
date: d('2026-08-20'),
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 12,
|
||||
},
|
||||
'PRODUCTION_COLONY',
|
||||
DEFAULTS,
|
||||
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
|
||||
);
|
||||
expect(rating).toMatchObject({
|
||||
light: 'red',
|
||||
reasonCode: 'NATURAL_FALL_SEASON',
|
||||
postTreatmentUntil: null,
|
||||
});
|
||||
expect(rating.band).toMatchObject({ redAbove: 10 });
|
||||
});
|
||||
|
||||
it('has no light for a method without a band or a missing value', () => {
|
||||
expect(
|
||||
rateMeasurement(
|
||||
{
|
||||
date: d('2026-04-10'),
|
||||
method: 'POWDERED_SUGAR',
|
||||
calculatedValue: 9,
|
||||
},
|
||||
null,
|
||||
DEFAULTS,
|
||||
[],
|
||||
),
|
||||
).toEqual({
|
||||
light: null,
|
||||
reasonCode: null,
|
||||
band: null,
|
||||
postTreatmentUntil: null,
|
||||
});
|
||||
expect(
|
||||
rateMeasurement(
|
||||
{
|
||||
date: d('2026-08-10'),
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: null,
|
||||
},
|
||||
null,
|
||||
DEFAULTS,
|
||||
[],
|
||||
).light,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user