feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI

- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

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 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -644,6 +644,54 @@ describe('daily evaluation rules', () => {
...unknown[],
];
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
expect(values).toEqual(['2026-06-03', '2026-06-10']);
// One day of margin on both sides: the SQL end day is a plain UTC date,
// the event day is the Berlin day of the withdrawal-window helper.
expect(values).toEqual(['2026-06-02', '2026-06-11']);
});
it('bounds the withdrawal query by the Berlin day of the run, not by the instant it is given', async () => {
const { db, queryRaw } = fakeDb({});
const rule = DAILY_EVALUATION_RULES.find(
(r) => r.type === 'WithdrawalEnded',
)!;
// 2026-06-10T23:30Z is already 2026-06-11 in Berlin.
await rule.occurrences(db, new Date('2026-06-10T23:30:00.000Z'), {
seasons,
day: '2026-06-11',
});
const [, ...values] = queryRaw.mock.calls[0] as [
readonly string[],
...unknown[],
];
expect(values).toEqual(['2026-06-03', '2026-06-12']);
});
it('takes the end day of a treatment stored with a time part from the Berlin day', async () => {
// 2026-06-01T22:30Z is 00:30 on 2026-06-02 in Berlin: one withdrawal day
// later the period ends on 2026-06-03, the first day of the window. The
// plain UTC date of the SQL would say 2026-06-02.
const { db } = fakeDb({
treatments: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: new Date('2026-06-01T22:30:00.000Z'),
endDate: null,
withdrawalPeriodDays: 1,
},
],
});
const events = await eventsOn(db, '2026-06-10');
expect(events).toEqual([
expect.objectContaining({
type: 'WithdrawalEnded',
payload: { colonyId: 'colony-1', endedOn: '2026-06-03' },
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-03',
}),
]);
});
});
@@ -123,15 +123,19 @@ interface TreatmentRow {
/** Treatment: the day after which honey may be harvested again. */
const withdrawalEnded: DailyRule<'WithdrawalEnded'> = {
type: 'WithdrawalEnded',
async occurrences(db, today) {
async occurrences(db, _today, { day: runDay }) {
// Prisma cannot compare column arithmetic; the days are compared as
// dates, so the session time zone plays no part.
// dates, so the session time zone plays no part. The SQL day is a plain
// UTC date, the event day is the Berlin day of the withdrawal-window
// helper: they can differ by a day for a treatment stored with a time
// part, so the query takes one day more on each side and the helper's
// day decides (catchUpEvents keeps the window).
const treatments = await db.$queryRaw<TreatmentRow[]>`
SELECT "id", "colonyId", "date", "endDate", "withdrawalPeriodDays"
FROM "Treatment"
WHERE "withdrawalPeriodDays" IS NOT NULL
AND (COALESCE("endDate", "date") + "withdrawalPeriodDays" * interval '1 day')::date
BETWEEN ${utcDay(dayAfter(today, -CATCH_UP_DAYS))}::date AND ${utcDay(today)}::date`;
BETWEEN ${addDays(runDay, -CATCH_UP_DAYS - 1)}::date AND ${addDays(runDay, 1)}::date`;
return treatments.flatMap((treatment) => {
const day = withdrawalEndDay(treatment);
if (day === null) {