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:
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 }));
|
||||
}
|
||||
Reference in new issue
Block a user