fix: compute mites/day for natural mite fall varroa measurements

VarroaMeasurement.calculatedValue was already used as "Milben/Tag" by
the VARROA_THRESHOLD automation (thresholdMitesPerDay) and the annual
PDF report, but no client ever actually computed it - the frontend
form only collects raw miteCount and durationMinutes. That meant the
threshold check was comparing a raw, un-normalized mite count against
a per-day threshold: e.g. 12 mites counted over 5 days (2.4/day,
healthy) would incorrectly trigger the same alert as 12 mites counted
over 1 day (a real problem).

VarroaService now derives calculatedValue = miteCount / duration in
days for NATURAL_MITE_FALL measurements, on create and (only when no
value exists yet) on update, without ever overwriting an explicitly
provided or already-stored value. Other methods (Puderzucker,
Waschmethode, Drohnenbrutkontrolle) use a different, non-time-based
metric and are left for manual entry rather than guessing an
unverified formula.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-07 12:01:02 +02:00
1 parent 985ea09556
commit 792806b967
2 files changed
+156 -2

No files matched your search

@@ -100,4 +100,106 @@ describe('VarroaService', () => {
expect(queryArg.where.colonyId).toBe('colony-1');
expect(queryArg.orderBy.date).toBe('asc');
});
describe('calculatedValue auto-computation', () => {
it('computes mites/day for a natural mite fall count on create', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
method: 'NATURAL_MITE_FALL',
miteCount: 45,
durationMinutes: 3 * 24 * 60,
},
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBe(15);
});
it('does not compute calculatedValue for a non-time-based method', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
method: 'POWDERED_SUGAR',
miteCount: 8,
},
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBeUndefined();
});
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
method: 'NATURAL_MITE_FALL',
miteCount: 45,
durationMinutes: 3 * 24 * 60,
calculatedValue: 99,
},
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBe(99);
});
it('backfills calculatedValue on update when it was never set', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue({
...measurement,
method: 'NATURAL_MITE_FALL',
miteCount: null,
durationMinutes: null,
calculatedValue: null,
});
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update(
'varroa-1',
{ miteCount: 20, durationMinutes: 24 * 60 },
'user-1',
);
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(updateArg.data.calculatedValue).toBe(20);
});
it('never overwrites an already-stored calculatedValue on an unrelated edit', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue({
...measurement,
method: 'NATURAL_MITE_FALL',
miteCount: 45,
durationMinutes: 3 * 24 * 60,
calculatedValue: 15,
});
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update('varroa-1', { assessment: 'stabil' }, 'user-1');
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(updateArg.data.calculatedValue).toBeUndefined();
});
});
});
+54 -2
View File
@@ -6,6 +6,30 @@ import { VarroaMeasurement } from '../../generated/prisma';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
const MINUTES_PER_DAY = 24 * 60;
// Natural mite fall is counted on a sticky-board insert left in the hive
// for some duration and is conventionally normalized to a daily rate
// ("Milben/Tag") - both the VARROA_THRESHOLD automation config
// (thresholdMitesPerDay) and the annual PDF report already assume
// calculatedValue is in that unit. The other methods (Puderzucker,
// Waschmethode, Drohnenbrutkontrolle) report a different, non-time-based
// metric (mites per fixed bee sample); there is no single verified
// formula for those here, so calculatedValue is left for the user to
// enter manually for those methods instead of guessing one.
function computeNaturalMiteFallPerDay(
miteCount: number | null | undefined,
durationMinutes: number | null | undefined,
): number | undefined {
if (miteCount === null || miteCount === undefined) {
return undefined;
}
if (!durationMinutes || durationMinutes <= 0) {
return undefined;
}
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
}
@Injectable()
export class VarroaService {
constructor(
@@ -41,8 +65,18 @@ export class VarroaService {
dto: CreateVarroaMeasurementDto,
actingUserId: string,
): Promise<VarroaMeasurement> {
const calculatedValue =
dto.calculatedValue ??
(dto.method === 'NATURAL_MITE_FALL'
? computeNaturalMiteFallPerDay(dto.miteCount, dto.durationMinutes)
: undefined);
const measurement = await this.prisma.varroaMeasurement.create({
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
data: {
...dto,
calculatedValue,
date: new Date(dto.date),
userId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'VarroaMeasurement',
@@ -61,9 +95,27 @@ export class VarroaService {
actingUserId: string,
): Promise<VarroaMeasurement> {
const before = await this.findById(id);
// Only auto-fill calculatedValue when nothing has set it yet - never
// overwrite a value the user (or a prior auto-fill) already stored,
// even if this edit only touches an unrelated field like notes.
const shouldAutoCompute =
dto.calculatedValue === undefined && before.calculatedValue === null;
const method = dto.method ?? before.method;
const calculatedValue =
dto.calculatedValue ??
(shouldAutoCompute && method === 'NATURAL_MITE_FALL'
? computeNaturalMiteFallPerDay(
dto.miteCount ?? before.miteCount,
dto.durationMinutes ?? before.durationMinutes,
)
: undefined);
const measurement = await this.prisma.varroaMeasurement.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
data: {
...dto,
calculatedValue,
date: dto.date ? new Date(dto.date) : undefined,
},
});
await this.auditLog.record({
entityType: 'VarroaMeasurement',