- 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]>
439 lines
12 KiB
TypeScript
439 lines
12 KiB
TypeScript
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 }));
|
|
}
|