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:' | 'suggestion:' */ 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> > = { 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> = { inspection: 'Durchsicht', varroa: 'Varroa messen', feeding: 'Füttern', treatment: 'Behandlung', 'winter-check': 'Winterkontrolle', }; const PRIORITY_RANK: Readonly> = { 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>> = { 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(); 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(); 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 })); }