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

@@ -0,0 +1,438 @@
import type {
SuggestionIntent,
SuggestionMode,
TaskPriority,
} from '../../generated/prisma';
import { daysBetween } from '../common/dates';
import type {
PhaseSourceKey,
PhenologyPhaseKey,
SeasonSegmentKey,
} from '../season/season-types';
export type NextStepForm =
'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check';
export interface NextStep {
/** 'task:<id>' | 'suggestion:<id>' */
key: string;
kind: 'TASK' | 'SUGGESTION';
intent: SuggestionIntent | null;
title: string;
/** At most 2 short sentences. */
reasons: string[];
/** YYYY-MM-DD */
dueOn: string | null;
urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE';
/** The suggestions behind the step (all members of a merged step); [] for a task. */
suggestionIds: string[];
action:
| { type: 'CAPTURE'; form: NextStepForm; href: string }
| { type: 'ACCEPT'; suggestionId: string }
| { type: 'CLEANUP'; suggestionId: string }
| { type: 'OPEN_TASK'; taskId: string; href: string };
dismissible: boolean;
}
export interface ColonyNextSteps {
colonyId: string;
segment: SeasonSegmentKey;
phase: PhenologyPhaseKey | null;
phaseSource: PhaseSourceKey | null;
inspectionDueOn: string | null;
withdrawalUntil: string | null;
/** At most `maxStepsPerColony` (3). */
steps: NextStep[];
}
/** An open task or an open suggestion of one colony, ready to be ranked. */
export interface StepSource {
kind: 'TASK' | 'SUGGESTION';
id: string;
intent: SuggestionIntent | null;
mode: SuggestionMode | null;
status: string;
priority: TaskPriority;
/** YYYY-MM-DD */
dueOn: string | null;
createdAt: Date;
title: string;
reasons: string[];
colonyId: string;
}
/** Spec 8.4 point 3. A task without a suggestion counts as FOLLOW_UP. */
export const INTENT_ORDER: readonly SuggestionIntent[] = [
'VARROA_TREAT',
'QUEEN_CHECK',
'QUEEN_ACCEPTANCE',
'SWARM_CHECK',
'SPLIT_PLAN',
'FEED',
'INSPECT',
'VARROA_RECHECK',
'HEALTH_CHECK',
'SUMMER_TREATMENT',
'VARROA_MEASURE',
'WINTER_CHECK',
'HONEY_SUPER',
'QUEEN_REPLACE',
'FOLLOW_UP',
'CLEANUP',
'STOCK_REORDER',
];
export const FORM_OF_INTENT: Readonly<
Partial<Record<SuggestionIntent, NextStepForm>>
> = {
INSPECT: 'inspection',
SWARM_CHECK: 'inspection',
QUEEN_CHECK: 'inspection',
QUEEN_ACCEPTANCE: 'inspection',
HONEY_SUPER: 'inspection',
HEALTH_CHECK: 'inspection',
VARROA_MEASURE: 'varroa',
VARROA_RECHECK: 'varroa',
FEED: 'feeding',
VARROA_TREAT: 'treatment',
SUMMER_TREATMENT: 'treatment',
WINTER_CHECK: 'winter-check',
};
/** Title of the merged step; the second title is appended with "mit". */
const FORM_TITLES: Readonly<Record<NextStepForm, string>> = {
inspection: 'Durchsicht',
varroa: 'Varroa messen',
feeding: 'Füttern',
treatment: 'Behandlung',
'winter-check': 'Winterkontrolle',
};
const PRIORITY_RANK: Readonly<Record<TaskPriority, number>> = {
URGENT: 0,
HIGH: 1,
NORMAL: 2,
LOW: 3,
};
const INTENT_RANK = new Map(
INTENT_ORDER.map((intent, index) => [intent, index] as const),
);
const SOON_DAYS = 3;
const LATER_TIER_DAYS = 14;
const MAX_REASONS = 2;
const FIRST_AUTUMN_MONTH = 7;
const TASKS_HREF = '/tasks';
const DATELESS_TIER = 4;
export interface StepOptions {
/** The user may accept suggestions (tasks:create). Default true. */
canAccept?: boolean;
}
export interface TopStepsOptions extends StepOptions {
limit: number;
}
function intentRank(source: StepSource): number {
return INTENT_RANK.get(source.intent ?? 'FOLLOW_UP') ?? INTENT_ORDER.length;
}
/** Spec 8.4 point 1: 0 = now, 1 <= 3 days, 2 <= 14 days, 3 later, 4 no date. */
function urgencyTier(source: StepSource, today: string): number {
if (source.status === 'IN_PROGRESS') {
return 0;
}
if (source.dueOn === null) {
return source.priority === 'URGENT' ? 0 : DATELESS_TIER;
}
const days = daysBetween(today, source.dueOn);
if (days <= 0) {
return 0;
}
if (days <= SOON_DAYS) {
return 1;
}
return days <= LATER_TIER_DAYS ? 2 : 3;
}
function compareText(a: string, b: string): number {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}
/** The total order of the spec: urgency, priority, intent, due day, createdAt, id. */
export function compareSources(
a: StepSource,
b: StepSource,
today: string,
): number {
return (
urgencyTier(a, today) - urgencyTier(b, today) ||
PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] ||
intentRank(a) - intentRank(b) ||
compareText(a.dueOn ?? '9999-12-31', b.dueOn ?? '9999-12-31') ||
a.createdAt.getTime() - b.createdAt.getTime() ||
compareText(a.id, b.id)
);
}
export function rankSteps(
items: readonly StepSource[],
today: string,
): StepSource[] {
return [...items].sort((a, b) => compareSources(a, b, today));
}
function formOf(source: StepSource): NextStepForm | undefined {
return source.kind === 'SUGGESTION' && source.intent !== null
? FORM_OF_INTENT[source.intent]
: undefined;
}
/**
* Intents that merge into one step with the "plain" member of their family
* (spec 8.4 says: same form = one step; the B3 example of spec 8.5 shows
* "Durchsicht mit Schwarmkontrolle" next to a separate "Honigraum prüfen",
* although both open the inspection form - the example wins).
* inspection family: INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE
* varroa family: VARROA_MEASURE, VARROA_RECHECK
* Everything else (HONEY_SUPER, HEALTH_CHECK, FEED, VARROA_TREAT,
* SUMMER_TREATMENT, WINTER_CHECK ...) never merges.
*/
const MERGE_FAMILY: Readonly<Partial<Record<SuggestionIntent, NextStepForm>>> =
{
INSPECT: 'inspection',
SWARM_CHECK: 'inspection',
QUEEN_CHECK: 'inspection',
QUEEN_ACCEPTANCE: 'inspection',
VARROA_MEASURE: 'varroa',
VARROA_RECHECK: 'varroa',
};
/** The member whose title is the form's own title, by intent. */
const PLAIN_INTENT: Readonly<
Record<'inspection' | 'varroa', SuggestionIntent>
> = { inspection: 'INSPECT', varroa: 'VARROA_MEASURE' };
function familyOf(source: StepSource): NextStepForm | undefined {
return source.kind === 'SUGGESTION' && source.intent !== null
? MERGE_FAMILY[source.intent]
: undefined;
}
/**
* Suggestions of one merge family (table above) of one colony become one
* step, when at least one of them is a DO suggestion (PLAN suggestions alone
* are decisions, not a form to fill in). The group keeps the position of its
* best-ranked member; `ranked` must already be ranked.
*/
export function mergeSameForm(ranked: readonly StepSource[]): StepSource[][] {
const members = new Map<string, StepSource[]>();
for (const item of ranked) {
const family = familyOf(item);
if (family !== undefined) {
const key = `${item.colonyId}|${family}`;
members.set(key, [...(members.get(key) ?? []), item]);
}
}
const groups: StepSource[][] = [];
const placed = new Set<string>();
for (const item of ranked) {
const family = familyOf(item);
const key = family === undefined ? null : `${item.colonyId}|${family}`;
const group = key === null ? undefined : members.get(key);
const mergeable =
group !== undefined &&
group.length > 1 &&
group.some((member) => member.mode === 'DO');
if (key === null || !mergeable) {
groups.push([item]);
} else if (!placed.has(key)) {
placed.add(key);
groups.push(group);
}
}
return groups;
}
function urgencyOf(dueOn: string | null, today: string): NextStep['urgency'] {
if (dueOn === null) {
return 'NONE';
}
const days = daysBetween(today, dueOn);
if (days < 0) {
return 'OVERDUE';
}
if (days === 0) {
return 'TODAY';
}
return days <= SOON_DAYS ? 'SOON' : 'LATER';
}
function captureHref(
form: NextStepForm,
colonyId: string,
today: string,
): string {
const query = `colonyId=${encodeURIComponent(colonyId)}`;
switch (form) {
case 'inspection':
return `/inspections/new?${query}`;
case 'varroa':
return `/varroa/new?${query}`;
case 'feeding':
return `/feedings/new?${query}`;
case 'treatment':
return `/treatments/new?${query}`;
case 'winter-check': {
const month = Number(today.slice(5, 7));
const season = month >= FIRST_AUTUMN_MONTH ? 'AUTUMN' : 'SPRING';
return `/winter?${query}&season=${season}`;
}
}
}
function capture(
form: NextStepForm,
colonyId: string,
today: string,
): NextStep['action'] {
return {
type: 'CAPTURE',
form,
href: captureHref(form, colonyId, today),
};
}
function actionOf(
source: StepSource,
today: string,
canAccept: boolean,
): NextStep['action'] | null {
if (source.kind === 'TASK') {
return { type: 'OPEN_TASK', taskId: source.id, href: TASKS_HREF };
}
if (source.intent === 'CLEANUP') {
return { type: 'CLEANUP', suggestionId: source.id };
}
if (source.mode === 'PLAN' && canAccept) {
return { type: 'ACCEPT', suggestionId: source.id };
}
const form = formOf(source);
return form === undefined ? null : capture(form, source.colonyId, today);
}
/** Whether the user can do anything with this step (else it is not shown). */
function isActionable(
source: StepSource,
today: string,
canAccept: boolean,
): boolean {
return actionOf(source, today, canAccept) !== null;
}
/**
* The specific cause first (the step that is not the plain form, "Weiselzellen
* gesehen"), then the plain one ("Letzte Durchsicht vor 8 Tagen").
*/
function isPlain(form: NextStepForm, item: StepSource): boolean {
return (
(form === 'inspection' || form === 'varroa') &&
item.intent === PLAIN_INTENT[form]
);
}
/**
* The specific cause first ("Weiselzellen gesehen"), then the plain member
* ("Letzte Durchsicht vor 8 Tagen"), at most two.
*/
function mergedReasons(
form: NextStepForm,
group: readonly StepSource[],
): string[] {
const ordered = [
...group.filter((item) => !isPlain(form, item)),
...group.filter((item) => isPlain(form, item)),
];
const firsts = ordered.flatMap((item) => item.reasons.slice(0, 1));
const rest = ordered.flatMap((item) => item.reasons.slice(1));
return [...new Set([...firsts, ...rest])].slice(0, MAX_REASONS);
}
function mergedTitle(form: NextStepForm, group: readonly StepSource[]): string {
const second = group.find((item) => !isPlain(form, item));
const formTitle = FORM_TITLES[form];
return second === undefined ? formTitle : `${formTitle} mit ${second.title}`;
}
/** The earliest due day of the members (null when none has one). */
function earliestDue(group: readonly StepSource[]): string | null {
const days = group.flatMap((item) =>
item.dueOn === null ? [] : [item.dueOn],
);
return days.length === 0 ? null : days.reduce((a, b) => (a <= b ? a : b));
}
/** One step from a ranked group (a single item, or a merged group). */
export function toNextStep(
group: readonly StepSource[],
today: string,
options: StepOptions = {},
): NextStep {
const [head] = group;
const canAccept = options.canAccept ?? true;
const form = formOf(head);
if (group.length > 1 && form !== undefined) {
return {
key: `suggestion:${head.id}`,
kind: 'SUGGESTION',
intent: head.intent,
title: mergedTitle(form, group),
reasons: mergedReasons(form, group),
dueOn: earliestDue(group),
urgency: urgencyOf(earliestDue(group), today),
suggestionIds: group.map((item) => item.id),
action: capture(form, head.colonyId, today),
dismissible: true,
};
}
const action = actionOf(head, today, canAccept);
if (action === null) {
throw new Error(`Step ${head.id} has no action`);
}
return {
key: `${head.kind === 'TASK' ? 'task' : 'suggestion'}:${head.id}`,
kind: head.kind,
intent: head.intent,
title: head.title,
reasons: head.reasons.slice(0, MAX_REASONS),
dueOn: head.dueOn,
urgency:
head.status === 'IN_PROGRESS' ? 'TODAY' : urgencyOf(head.dueOn, today),
suggestionIds: head.kind === 'SUGGESTION' ? [head.id] : [],
action,
dismissible: head.kind === 'SUGGESTION',
};
}
/**
* The next steps of one colony: what the user can act on, ranked, merged by
* form and cut to `limit`.
*/
export function topSteps(
items: readonly StepSource[],
today: string,
options: TopStepsOptions,
): NextStep[] {
const canAccept = options.canAccept ?? true;
const actionable = items.filter((item) =>
isActionable(item, today, canAccept),
);
return mergeSameForm(rankSteps(actionable, today))
.slice(0, options.limit)
.map((group) => toNextStep(group, today, { canAccept }));
}