feat: Ein-/Auswinterung und Winterverluste je Volk
- WinterCheck per colony, winter and season (autumn/spring), every field editable; loss causes limited to the COLOSS monitoring categories. - Summary per winter and apiary: loss rate lost/wintered, marked provisional while colonies are not yet checked in spring - an unchecked colony never counts as survived. - A loss marks the colony deceased only on explicit request, in the same transaction and audited. - Permissions winter-checks:read/create/update granted by migration to beekeeper and staff roles; winter checks included in backups. - Page "Überwinterung": next action first, one button per row until the colony is wintered, German number format. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0d23963f07
commit
fac66882fa
26 files changed
+2020
-2
No files matched your search
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
summarizeWinter,
|
||||
validateWinterCheck,
|
||||
WinterSummarySource,
|
||||
winterYearFor,
|
||||
} from './winter-check-rules';
|
||||
|
||||
const d = (value: string) => new Date(`${value}T12:00:00.000Z`);
|
||||
|
||||
describe('winterYearFor', () => {
|
||||
it('assigns autumn dates to the winter starting that year', () => {
|
||||
expect(winterYearFor(d('2026-09-15'))).toBe(2026);
|
||||
expect(winterYearFor(d('2026-07-01'))).toBe(2026);
|
||||
});
|
||||
|
||||
it('assigns spring dates to the winter that started the year before', () => {
|
||||
expect(winterYearFor(d('2027-03-20'))).toBe(2026);
|
||||
expect(winterYearFor(d('2027-06-30'))).toBe(2026);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWinterCheck', () => {
|
||||
it('accepts a plain autumn check', () => {
|
||||
expect(
|
||||
validateWinterCheck({ season: 'AUTUMN', outcome: null, lossCause: null }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an outcome on an autumn check', () => {
|
||||
expect(
|
||||
validateWinterCheck({
|
||||
season: 'AUTUMN',
|
||||
outcome: 'SURVIVED',
|
||||
lossCause: null,
|
||||
}),
|
||||
).toMatch(/Einwinterung/);
|
||||
});
|
||||
|
||||
it('requires an outcome on a spring check', () => {
|
||||
expect(
|
||||
validateWinterCheck({ season: 'SPRING', outcome: null, lossCause: null }),
|
||||
).toMatch(/Ergebnis/);
|
||||
});
|
||||
|
||||
it('requires a loss cause exactly when the colony was lost', () => {
|
||||
expect(
|
||||
validateWinterCheck({
|
||||
season: 'SPRING',
|
||||
outcome: 'LOST',
|
||||
lossCause: null,
|
||||
}),
|
||||
).toMatch(/Verlustursache/);
|
||||
expect(
|
||||
validateWinterCheck({
|
||||
season: 'SPRING',
|
||||
outcome: 'SURVIVED',
|
||||
lossCause: 'DEAD_OR_EMPTY',
|
||||
}),
|
||||
).toMatch(/Verlustursache/);
|
||||
expect(
|
||||
validateWinterCheck({
|
||||
season: 'SPRING',
|
||||
outcome: 'LOST',
|
||||
lossCause: 'QUEEN_PROBLEM',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function check(overrides: Partial<WinterSummarySource>): WinterSummarySource {
|
||||
return {
|
||||
colonyId: 'c1',
|
||||
apiaryId: 'a',
|
||||
apiaryName: 'Stand A',
|
||||
season: 'AUTUMN',
|
||||
outcome: null,
|
||||
lossCause: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('summarizeWinter', () => {
|
||||
it('counts outcomes against the colonies that went into winter', () => {
|
||||
const summary = summarizeWinter([
|
||||
check({ colonyId: 'c1' }),
|
||||
check({ colonyId: 'c1', season: 'SPRING', outcome: 'SURVIVED' }),
|
||||
check({ colonyId: 'c2' }),
|
||||
check({
|
||||
colonyId: 'c2',
|
||||
season: 'SPRING',
|
||||
outcome: 'LOST',
|
||||
lossCause: 'DEAD_OR_EMPTY',
|
||||
}),
|
||||
check({ colonyId: 'c3' }),
|
||||
check({ colonyId: 'c3', season: 'SPRING', outcome: 'WEAK' }),
|
||||
check({ colonyId: 'c4' }),
|
||||
]);
|
||||
|
||||
expect(summary.total).toMatchObject({
|
||||
wintered: 4,
|
||||
survived: 1,
|
||||
weak: 1,
|
||||
lost: 1,
|
||||
lostByCause: { DEAD_OR_EMPTY: 1, QUEEN_PROBLEM: 0, NATURAL_DISASTER: 0 },
|
||||
pendingSpring: 1,
|
||||
springWithoutAutumn: 0,
|
||||
lossRatePercent: 25,
|
||||
isProvisional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('never counts a colony without spring check as survived', () => {
|
||||
const summary = summarizeWinter([check({ colonyId: 'c1' })]);
|
||||
expect(summary.total.survived).toBe(0);
|
||||
expect(summary.total.pendingSpring).toBe(1);
|
||||
expect(summary.total.isProvisional).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps spring checks without autumn check out of the loss rate', () => {
|
||||
const summary = summarizeWinter([
|
||||
check({ colonyId: 'c1' }),
|
||||
check({ colonyId: 'c1', season: 'SPRING', outcome: 'SURVIVED' }),
|
||||
check({
|
||||
colonyId: 'c9',
|
||||
season: 'SPRING',
|
||||
outcome: 'LOST',
|
||||
lossCause: 'NATURAL_DISASTER',
|
||||
}),
|
||||
]);
|
||||
expect(summary.total).toMatchObject({
|
||||
wintered: 1,
|
||||
lost: 0,
|
||||
springWithoutAutumn: 1,
|
||||
lossRatePercent: 0,
|
||||
isProvisional: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no loss rate when nothing was wintered', () => {
|
||||
expect(summarizeWinter([]).total.lossRatePercent).toBeNull();
|
||||
});
|
||||
|
||||
it('groups by apiary, sorted by name', () => {
|
||||
const summary = summarizeWinter([
|
||||
check({ colonyId: 'c1', apiaryId: 'b', apiaryName: 'Wald' }),
|
||||
check({ colonyId: 'c2', apiaryId: 'a', apiaryName: 'Obstwiese' }),
|
||||
]);
|
||||
expect(summary.apiaries.map((apiary) => apiary.apiaryName)).toEqual([
|
||||
'Obstwiese',
|
||||
'Wald',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user