fix: T4a browser check findings
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s

- Withdrawal wording from one backend helper everywhere ("bis
  einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
  crosses New Year); withdrawal-status returns lastBlockedDay and the
  ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
  product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
  shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
  toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
  colony codes; collapsible automation rule cards; varroa list newest
  first with chart axes; text fixes

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 18:26:09 +02:00
1 parent 32c61c8e14
commit c2f71bcb03
101 files changed
+2189 -271

No files matched your search

@@ -699,7 +699,8 @@ describe('BottlingService', () => {
const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'B3: Wartezeit nach Oxuvar endet am 20.07.2026.',
message:
'B3: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.',
refs: [{ type: 'Colony', id: 'c1' }],
};
@@ -3,6 +3,14 @@ import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('CalendarService', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let service: CalendarService;
let prisma: {
task: { findMany: jest.Mock };
@@ -11,6 +19,8 @@ describe('CalendarService', () => {
feeding: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
queenColonyHistory: { findMany: jest.Mock };
colony: { findMany: jest.Mock };
apiary: { findMany: jest.Mock };
};
let resolver: { getPermissions: jest.Mock };
@@ -24,6 +34,8 @@ describe('CalendarService', () => {
feeding: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
apiary: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new CalendarService(
prisma as unknown as PrismaService,
@@ -44,7 +56,12 @@ describe('CalendarService', () => {
},
]);
prisma.harvest.findMany.mockResolvedValueOnce([
{ id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' },
{
id: 'h1',
date: new Date('2026-06-10'),
apiaryId: 'a1',
apiary: { name: 'Teststand Nord' },
},
]);
const events = await service.getEvents(
@@ -62,6 +79,73 @@ describe('CalendarService', () => {
]);
});
it('names the colony or apiary of every event in context', async () => {
prisma.task.findMany.mockResolvedValueOnce([
{
id: 't1',
title: 'Füttern',
dueDate: new Date('2026-06-05'),
relatedType: 'COLONY',
relatedId: 'c1',
},
{
id: 't2',
title: 'Stand putzen',
dueDate: new Date('2026-06-06'),
relatedType: 'APIARY',
relatedId: 'a1',
},
{
id: 't3',
title: 'Allgemein',
dueDate: new Date('2026-06-07'),
relatedType: 'GENERAL',
relatedId: null,
},
]);
prisma.colony.findMany.mockResolvedValueOnce([{ id: 'c1', code: 'V-001' }]);
prisma.apiary.findMany.mockResolvedValueOnce([
{ id: 'a1', name: 'Teststand Nord' },
]);
prisma.inspection.findMany.mockResolvedValueOnce([
{
id: 'i1',
date: new Date('2026-06-01'),
colonyId: 'c1',
colony: { code: 'V-001' },
},
]);
prisma.feeding.findMany.mockResolvedValueOnce([
{
id: 'f1',
date: new Date('2026-06-02'),
colonyId: 'c1',
colony: { code: 'V-001' },
},
]);
prisma.harvest.findMany.mockResolvedValueOnce([
{
id: 'h1',
date: new Date('2026-06-10'),
apiaryId: 'a1',
apiary: { name: 'Teststand Nord' },
},
]);
const events = await service.getEvents(
{ from: new Date('2026-06-01'), to: new Date('2026-06-30') },
'user-1',
);
const contextOf = (id: string) => events.find((e) => e.id === id)?.context;
expect(contextOf('i1')).toBe('V-001');
expect(contextOf('f1')).toBe('V-001');
expect(contextOf('t1')).toBe('V-001');
expect(contextOf('t2')).toBe('Teststand Nord');
expect(contextOf('t3')).toBeUndefined();
expect(contextOf('h1')).toBe('Teststand Nord');
});
it('filters colony-linked events by apiary when apiaryId is given', async () => {
await service.getEvents(
{
@@ -135,6 +219,9 @@ describe('CalendarService', () => {
title: 'Wartezeit V-001 · Oxuvar',
relatedType: 'Colony',
relatedId: 'c1',
context: 'V-001',
product: 'Oxuvar',
detail: 'bis einschließlich 29.06. – ab 30.06. wieder erntefähig',
},
]);
});
@@ -4,6 +4,10 @@ import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
import { withdrawalSpanOf } from '../withdrawal/withdrawal-span';
import {
lastBlockedDay,
withdrawalRangeText,
} from '../withdrawal/withdrawal-text';
type TreatmentWithColony = Prisma.TreatmentGetPayload<{
include: { colony: true };
@@ -25,6 +29,12 @@ export interface CalendarEvent {
/** WITHDRAWAL only: the last day the span covers (inclusive). */
endDate?: Date;
title: string;
/** Colony code or apiary name the event belongs to; absent for general tasks. */
context?: string;
/** WITHDRAWAL only: the treatment product. */
product?: string;
/** WITHDRAWAL only: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */
detail?: string;
relatedType: string;
relatedId: string;
}
@@ -71,7 +81,7 @@ export class CalendarService {
if (span === null) {
return [];
}
const lastDay = addDays(span.untilDay, -1);
const lastDay = lastBlockedDay(span.untilDay);
if (span.fromDay > toDay || lastDay < fromDay) {
return [];
}
@@ -82,6 +92,9 @@ export class CalendarService {
date: dayToDate(span.fromDay),
endDate: dayToDate(lastDay),
title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`,
context: treatment.colony.code,
product: treatment.product,
detail: withdrawalRangeText(span.untilDay),
relatedType: 'Colony',
relatedId: treatment.colonyId,
},
@@ -89,6 +102,60 @@ export class CalendarService {
});
}
/**
* Colony code or apiary name of the tasks that point at one. By ruling only
* COLONY and APIARY tasks get a context; other tasks (queen, honey batch,
* storage, general) have none.
*/
private async taskContexts(
tasks: readonly {
id: string;
relatedType: string;
relatedId: string | null;
}[],
): Promise<Map<string, string>> {
const idsOf = (type: string) => [
...new Set(
tasks.flatMap((task) =>
task.relatedType === type && task.relatedId !== null
? [task.relatedId]
: [],
),
),
];
const colonyIds = idsOf('COLONY');
const apiaryIds = idsOf('APIARY');
const noColonies: { id: string; code: string }[] = [];
const noApiaries: { id: string; name: string }[] = [];
const [colonies, apiaries] = await Promise.all([
colonyIds.length === 0
? Promise.resolve(noColonies)
: this.prisma.colony.findMany({
where: { id: { in: colonyIds } },
select: { id: true, code: true },
}),
apiaryIds.length === 0
? Promise.resolve(noApiaries)
: this.prisma.apiary.findMany({
where: { id: { in: apiaryIds } },
select: { id: true, name: true },
}),
]);
const names = new Map<string, string>([
...colonies.map((c) => [c.id, c.code] as const),
...apiaries.map((a) => [a.id, a.name] as const),
]);
const contexts = new Map<string, string>();
for (const task of tasks) {
const name =
task.relatedId === null ? undefined : names.get(task.relatedId);
if (name !== undefined) {
contexts.set(task.id, name);
}
}
return contexts;
}
async getEvents(
query: CalendarQuery,
userId: string,
@@ -132,6 +199,7 @@ export class CalendarService {
date: { gte: from, lte: to },
...(apiaryId ? { apiaryId } : {}),
},
include: { apiary: { select: { name: true } } },
}),
this.prisma.queenColonyHistory.findMany({
where: { startDate: { gte: from, lte: to }, ...colonyFilter },
@@ -140,6 +208,8 @@ export class CalendarService {
this.withdrawalEvents(query, canReadTreatments),
]);
const taskContexts = await this.taskContexts(tasks);
const events: CalendarEvent[] = [
...tasks
.filter((task) => task.dueDate !== null)
@@ -148,6 +218,7 @@ export class CalendarService {
type: 'TASK' as const,
date: task.dueDate!,
title: task.title,
context: taskContexts.get(task.id),
relatedType: 'Task',
relatedId: task.id,
})),
@@ -156,6 +227,7 @@ export class CalendarService {
type: 'INSPECTION' as const,
date: inspection.date,
title: `Durchsicht ${inspection.colony.code}`,
context: inspection.colony.code,
relatedType: 'Colony',
relatedId: inspection.colonyId,
})),
@@ -164,6 +236,7 @@ export class CalendarService {
type: 'TREATMENT' as const,
date: treatment.date,
title: `Behandlung ${treatment.colony.code} · ${treatment.product}`,
context: treatment.colony.code,
relatedType: 'Colony',
relatedId: treatment.colonyId,
})),
@@ -172,6 +245,7 @@ export class CalendarService {
type: 'FEEDING' as const,
date: feeding.date,
title: `Fütterung ${feeding.colony.code}`,
context: feeding.colony.code,
relatedType: 'Colony',
relatedId: feeding.colonyId,
})),
@@ -180,6 +254,7 @@ export class CalendarService {
type: 'HARVEST' as const,
date: harvest.date,
title: 'Ernte',
context: harvest.apiary.name,
relatedType: 'Apiary',
relatedId: harvest.apiaryId,
})),
@@ -188,6 +263,7 @@ export class CalendarService {
type: 'QUEEN_ASSIGNMENT' as const,
date: assignment.startDate,
title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`,
context: assignment.colony.code,
relatedType: 'Queen',
relatedId: assignment.queenId,
})),
@@ -0,0 +1,24 @@
import { endSentence, formatDayDe } from './format-day';
describe('formatDayDe', () => {
it('formats a day without and with the year', () => {
expect(formatDayDe('2026-05-05')).toBe('05.05.');
expect(formatDayDe('2026-05-05', true)).toBe('05.05.2026');
});
});
describe('endSentence', () => {
it('adds the period when the text has none', () => {
expect(endSentence('Weiselzellen gefunden')).toBe('Weiselzellen gefunden.');
});
it('does not double the period a formatted day already ends with', () => {
expect(endSentence(`Ableger von V-1 am ${formatDayDe('2026-10-13')}`)).toBe(
'Ableger von V-1 am 13.10.',
);
});
it('keeps a text that already ends with a period', () => {
expect(endSentence('Fertig.')).toBe('Fertig.');
});
});
+13
View File
@@ -0,0 +1,13 @@
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
export function formatDayDe(day: string, withYear = false): string {
const [year, month, date] = day.split('-');
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
}
/**
* Closes a sentence with one period. A day formatted by formatDayDe ends with
* its own dot ('13.10.'), which then is the end of the sentence.
*/
export function endSentence(text: string): string {
return text.endsWith('.') ? text : `${text}.`;
}
@@ -23,7 +23,8 @@ describe('plausibility warnings', () => {
const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.',
message:
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
refs: [{ type: 'Colony', id: 'c1' }],
};
@@ -260,7 +260,8 @@ describe('HarvestsService', () => {
const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'V-1: Wartezeit nach Oxuvar endet am 20.07.2026.',
message:
'V-1: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.',
refs: [{ type: 'Colony', id: 'colony-1' }],
};
@@ -106,5 +106,5 @@ export function renderSuggestion(
return override ? { ...rendered, title: override } : rendered;
}
export { formatDayDe } from './texts/format-day';
export { formatDayDe } from '../common/format-day';
export { formatNumberDe } from './texts/format-number';
@@ -28,3 +28,35 @@ describe('chain texts', () => {
expect(title('QUEEN_CELLS_SWARM_CHECK')).toBe('Schwarmkontrolle');
});
});
describe('chain texts: sentence ends', () => {
const lookup = {
colonyCode: () => 'V-1',
queenCode: () => 'Q-1',
treatmentProduct: () => null,
apiaryName: () => null,
stockItemName: () => null,
};
it('never ends a reason with two periods when a day closes the sentence', () => {
for (const code of Object.keys(CHAIN_TEXTS)) {
const reasons = CHAIN_TEXTS[code].render(
CHAIN_TEXTS[code].example,
lookup,
'2026-05-05',
).reasons;
for (const reason of reasons) {
expect(reason).not.toMatch(/\.\./);
}
}
});
it('writes the split day with a single period', () => {
const reasons = CHAIN_TEXTS.SPLIT_SCHEDULE_QUEEN.render(
CHAIN_TEXTS.SPLIT_SCHEDULE_QUEEN.example,
lookup,
'2026-05-05',
).reasons;
expect(reasons[0]).toBe('Ableger von V-1 am 01.05.');
});
});
@@ -2,7 +2,7 @@ import type {
SuggestionTextRegister,
SuggestionTextTemplate,
} from '../suggestion-texts';
import { formatDayDe } from './format-day';
import { endSentence, formatDayDe } from '../../common/format-day';
import type { EvidenceLookup } from '../evidence-lookup';
import type { SuggestionEvidence } from '../suggestion.types';
@@ -39,7 +39,9 @@ function splitTemplate(
render: (evidence, lookup) => ({
title,
reasons: [
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}.`,
endSentence(
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}`,
),
reason,
],
}),
@@ -59,7 +61,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = {
render: (evidence, lookup) => ({
title: 'Schwarmkontrolle',
reasons: [
`Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden.`,
endSentence(
`Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden`,
),
],
}),
},
@@ -80,7 +84,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = {
return {
title: 'Annahme der Königin prüfen',
reasons: [
`${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt.`,
endSentence(
`${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt`,
),
],
};
},
@@ -1,5 +1,5 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
import { formatDayDe } from './format-day';
import { formatDayDe } from '../../common/format-day';
const ENDED_VERBS: Readonly<Record<string, string>> = {
DECEASED: 'ist eingegangen',
@@ -1,5 +0,0 @@
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
export function formatDayDe(day: string, withYear = false): string {
const [year, month, date] = day.split('-');
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
}
@@ -1,4 +1,4 @@
import { formatDayDe } from './format-day';
import { formatDayDe } from '../../common/format-day';
import { formatNumberDe } from './format-number';
import type {
RenderedSuggestionText,
@@ -6,6 +6,14 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
describe('TreatmentsService', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let service: TreatmentsService;
let prisma: {
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
@@ -499,6 +507,9 @@ describe('TreatmentsService', () => {
{
colonyId: 'colony-1',
activeUntil: '2026-07-15T00:00:00.000Z',
lastBlockedDay: '2026-07-14',
withdrawalText:
'bis einschließlich 14.07. – ab 15.07. wieder erntefähig',
product: 'Ameisensäure',
},
]);
@@ -587,6 +598,9 @@ describe('TreatmentsService', () => {
{
colonyId: 'colony-1',
activeUntil: '2026-07-27T00:00:00.000Z',
lastBlockedDay: '2026-07-26',
withdrawalText:
'bis einschließlich 26.07. – ab 27.07. wieder erntefähig',
product: 'Ameisensäure',
},
]);
@@ -15,6 +15,10 @@ import {
import { runBulk } from '../common/bulk/run-bulk';
import { lockRowForUpdate } from '../prisma/row-lock';
import { berlinDayOf, dayToDate } from '../common/dates';
import {
lastBlockedDay,
withdrawalRangeText,
} from '../withdrawal/withdrawal-text';
import {
isInWindow,
withdrawalWindowOf,
@@ -27,6 +31,10 @@ import { UpdateTreatmentDto } from './dto/update-treatment.dto';
export interface WithdrawalStatus {
colonyId: string;
activeUntil: string;
/** Last blocked day (inclusive), 'YYYY-MM-DD'; activeUntil is the day after. */
lastBlockedDay: string;
/** Ready text: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */
withdrawalText: string;
product: string;
}
@@ -116,6 +124,8 @@ export class TreatmentsService {
return [...activeByColony.values()].map((window) => ({
colonyId: window.colonyId,
activeUntil: dayToDate(window.untilDay).toISOString(),
lastBlockedDay: lastBlockedDay(window.untilDay),
withdrawalText: withdrawalRangeText(window.untilDay),
product: window.product,
}));
}
@@ -40,6 +40,14 @@ const conflict = (
});
describe('WithdrawalCheckService', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let db: {
harvest: { findMany: jest.Mock };
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
@@ -269,7 +277,8 @@ describe('WithdrawalCheckService', () => {
{
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.',
message:
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
refs: [
{ type: 'Colony', id: 'c1' },
{ type: 'Treatment', id: 't1' },
@@ -279,6 +288,25 @@ describe('WithdrawalCheckService', () => {
]);
});
it('warns once per colony and treatment when several harvests feed the batch', async () => {
const warnings = await service.warningsFor(asDb(), [
conflict({ harvestId: 'h1' }),
conflict({ harvestId: 'h2' }),
conflict({ harvestId: 'h3', treatmentId: 't2', product: 'Apivar' }),
]);
expect(warnings.map((w) => w.message)).toEqual([
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
'B3: Wartezeit nach Apivar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
]);
expect(warnings[0].refs).toEqual([
{ type: 'Colony', id: 'c1' },
{ type: 'Treatment', id: 't1' },
{ type: 'Harvest', id: 'h1' },
{ type: 'Harvest', id: 'h2' },
]);
});
it('answers nothing without reading when there is no conflict', async () => {
expect(await service.warningsFor(asDb(), [])).toEqual([]);
expect(db.colony.findMany).not.toHaveBeenCalled();
@@ -300,7 +328,7 @@ describe('WithdrawalCheckService', () => {
priority: 'WARNUNG',
category: 'WITHDRAWAL',
message:
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026.',
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
relatedType: 'Harvest',
relatedId: 'h1',
link: '/harvests/h1',
@@ -337,7 +365,7 @@ describe('WithdrawalCheckService', () => {
{ message: string; dedupeKeyPrefix: string },
];
expect(input.message).toBe(
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026. Bereits abgefüllt: Los L-1, L-2.',
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig. Bereits abgefüllt: Los L-1, L-2.',
);
// Same key as without the lots: one notification per harvest and colony.
expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1');
@@ -7,13 +7,14 @@ import {
} from '../common/warnings/plausibility-warning';
import { NotificationsService } from '../notifications/notifications.service';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { formatDayDe } from '../suggestions/texts/format-day';
import { formatDayDe } from '../common/format-day';
import {
isInWindow,
withdrawalWindowOf,
type WithdrawalWindow,
} from '../treatments/withdrawal-window';
import { withdrawalSpanOf } from './withdrawal-span';
import { withdrawalRangeText } from './withdrawal-text';
/** A harvest taken from a colony while a treatment's withdrawal period ran. */
export interface WithdrawalConflict {
@@ -49,6 +50,22 @@ function longestCovering(
);
}
/**
* One group per (colony, treatment): several harvests of a colony that feed
* the same batch yield one warning, not one per harvest. Keeps the order of
* first appearance.
*/
function perColonyAndTreatment(
conflicts: readonly WithdrawalConflict[],
): WithdrawalConflict[][] {
const groups = new Map<string, WithdrawalConflict[]>();
for (const conflict of conflicts) {
const key = `${conflict.colonyId}:${conflict.treatmentId}`;
groups.set(key, [...(groups.get(key) ?? []), conflict]);
}
return [...groups.values()];
}
/**
* Checks harvests against the withdrawal windows (Wartezeit) of their
* colonies. Only the window helper decides what a window is
@@ -190,16 +207,22 @@ export class WithdrawalCheckService {
return [];
}
const codes = await this.colonyCodes(db, conflicts);
return conflicts.map((conflict) => ({
code: 'WITHDRAWAL_ACTIVE',
severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE,
message: `${codes.get(conflict.colonyId) ?? conflict.colonyId}: Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.`,
refs: [
{ type: 'Colony', id: conflict.colonyId },
{ type: 'Treatment', id: conflict.treatmentId },
{ type: 'Harvest', id: conflict.harvestId },
],
}));
return perColonyAndTreatment(conflicts).map((group) => {
const [first] = group;
return {
code: 'WITHDRAWAL_ACTIVE',
severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE,
message: `${codes.get(first.colonyId) ?? first.colonyId}: Wartezeit nach ${first.product} ${withdrawalRangeText(first.untilDay)}.`,
refs: [
{ type: 'Colony', id: first.colonyId },
{ type: 'Treatment', id: first.treatmentId },
...group.map((conflict) => ({
type: 'Harvest',
id: conflict.harvestId,
})),
],
};
});
}
/**
@@ -234,7 +257,7 @@ export class WithdrawalCheckService {
{
priority: 'WARNUNG',
category: 'WITHDRAWAL',
message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.${bottledText}`,
message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} ${withdrawalRangeText(conflict.untilDay)}.${bottledText}`,
relatedType: 'Harvest',
relatedId: conflict.harvestId,
link: `/harvests/${conflict.harvestId}`,
@@ -0,0 +1,37 @@
import { lastBlockedDay, withdrawalRangeText } from './withdrawal-text';
describe('lastBlockedDay', () => {
it('is the day before the first free day', () => {
expect(lastBlockedDay('2026-10-27')).toBe('2026-10-26');
expect(lastBlockedDay('2027-01-01')).toBe('2026-12-31');
});
});
describe('withdrawalRangeText', () => {
const today = '2026-10-04';
it('names the last blocked day (inclusive) and the first free day', () => {
// The window is [fromDay, untilDay): untilDay is the first free day.
expect(withdrawalRangeText('2026-10-27', today)).toBe(
'bis einschließlich 26.10. – ab 27.10. wieder erntefähig',
);
});
it('counts back over a month boundary', () => {
expect(withdrawalRangeText('2026-11-01', today)).toBe(
'bis einschließlich 31.10. – ab 01.11. wieder erntefähig',
);
});
it('adds the year when the first free day lies in another year than the last blocked day', () => {
expect(withdrawalRangeText('2027-01-01', today)).toBe(
'bis einschließlich 31.12.2026 – ab 01.01.2027 wieder erntefähig',
);
});
it('adds the year when the first free day lies in another year than today', () => {
expect(withdrawalRangeText('2027-01-10', today)).toBe(
'bis einschließlich 09.01.2027 – ab 10.01.2027 wieder erntefähig',
);
});
});
@@ -0,0 +1,30 @@
import { addDays, berlinToday } from '../common/dates';
import { formatDayDe } from '../common/format-day';
/**
* The window is [fromDay, untilDay): untilDay is the first free day, the day
* before it is the last blocked day (inclusive). The one place that subtracts
* that day; calendar and texts both read it.
*/
export function lastBlockedDay(untilDay: string): string {
return addDays(untilDay, -1);
}
/**
* The one wording of a withdrawal window's end: the last blocked day
* (inclusive) and the first free day. Every user-facing text (warnings,
* notifications, calendar, withdrawal status) builds on this; the days come
* from the window helper in treatments/, nothing is calculated twice. The
* year is added when the end is not in the current year or the window spans
* a year change.
*/
export function withdrawalRangeText(
untilDay: string,
today: string = berlinToday(),
): string {
const last = lastBlockedDay(untilDay);
const withYear =
untilDay.slice(0, 4) !== last.slice(0, 4) ||
untilDay.slice(0, 4) !== today.slice(0, 4);
return `bis einschließlich ${formatDayDe(last, withYear)} – ab ${formatDayDe(untilDay, withYear)} wieder erntefähig`;
}
@@ -39,6 +39,14 @@ const treatmentRow = (overrides: Record<string, unknown> = {}) => ({
});
describe('WithdrawalEventHandlers', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let check: {
conflictsForHarvests: jest.Mock;
conflictsForHoneyBatch: jest.Mock;
@@ -193,7 +201,7 @@ describe('WithdrawalEventHandlers', () => {
priority: 'INFO',
category: 'WITHDRAWAL',
message:
'B3 ist wieder erntefähig. Wartezeit nach Oxuvar endete am 20.08.2026.',
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
relatedType: 'Colony',
relatedId: 'c1',
link: '/colonies/c1',
@@ -5,12 +5,12 @@ import { isLivingColonyStatus } from '../colonies/colony-status';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { formatDayDe } from '../suggestions/texts/format-day';
import {
isInWindow,
withdrawalWindowOf,
} from '../treatments/withdrawal-window';
import { WithdrawalCheckService } from './withdrawal-check.service';
import { withdrawalRangeText } from './withdrawal-text';
const READY_RECIPIENT_PERMISSIONS = [
'harvests:create',
@@ -123,7 +123,7 @@ export class WithdrawalEventHandlers {
{
priority: 'INFO',
category: 'WITHDRAWAL',
message: `${treatment.colony.code} ist wieder erntefähig. Wartezeit nach ${treatment.product} endete am ${formatDayDe(endedOn, true)}.`,
message: `${treatment.colony.code}: Wartezeit nach ${treatment.product} ${withdrawalRangeText(endedOn)}.`,
relatedType: 'Colony',
relatedId: treatment.colonyId,
link: `/colonies/${treatment.colonyId}`,
@@ -1,4 +1,5 @@
import { INestApplication } from '@nestjs/common';
import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
@@ -152,6 +153,9 @@ describe('Calendar (e2e)', () => {
);
expect(span).toMatchObject({
title: `Wartezeit ${colonyCode} · Oxuvar`,
context: colonyCode,
product: 'Oxuvar',
detail: withdrawalRangeText('2027-06-20'),
relatedType: 'Colony',
relatedId: colonyId,
});
@@ -16,6 +16,8 @@ interface TreatmentBody {
interface WithdrawalStatusBody {
colonyId: string;
activeUntil: string;
lastBlockedDay: string;
withdrawalText: string;
product: string;
}
@@ -254,6 +256,9 @@ describe('Treatments (e2e)', () => {
{
colonyId,
activeUntil: '2026-07-15T00:00:00.000Z',
lastBlockedDay: '2026-07-14',
withdrawalText:
'bis einschließlich 14.07. – ab 15.07. wieder erntefähig',
product: 'Oxalsäure',
},
]);
@@ -1,4 +1,5 @@
import { INestApplication } from '@nestjs/common';
import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
@@ -264,7 +265,7 @@ describe('Withdrawal chain (e2e)', () => {
{
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
refs: [
{ type: 'Colony', id: colony.id },
{ type: 'Treatment', id: treatmentIds[0] },
@@ -283,7 +284,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(rows[0]).toMatchObject({
priority: 'WARNUNG',
category: 'WITHDRAWAL',
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar endet am 30.07.2026.`,
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
relatedType: 'Harvest',
relatedId: harvestId,
link: `/harvests/${harvestId}`,
@@ -332,7 +333,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(bottling.warnings).toHaveLength(1);
expect(bottling.warnings[0]).toMatchObject({
code: 'WITHDRAWAL_ACTIVE',
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
});
await drainDomainEvents(app, [bottling.id]);
@@ -515,7 +516,7 @@ describe('Withdrawal chain (e2e)', () => {
expect(rows[0]).toMatchObject({
priority: 'INFO',
category: 'WITHDRAWAL',
message: `${colony.code} ist wieder erntefähig. Wartezeit nach Oxuvar endete am 25.06.2026.`,
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-06-25')}.`,
relatedType: 'Colony',
link: `/colonies/${colony.id}`,
});
@@ -558,7 +559,7 @@ describe('Withdrawal chain (e2e)', () => {
where: { userId: readerId, relatedId: colony.id },
});
expect(rows).toHaveLength(1);
expect(rows[0].message).toContain('endete am 03.06.2026');
expect(rows[0].message).toContain(withdrawalRangeText('2026-06-03'));
expect(rows[0].message).toContain(colony.code);
});
});