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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

@@ -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,
};
}