feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI

- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

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 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

@@ -0,0 +1,99 @@
import type { Suggestion, SuggestionIntent } from "./types";
export type SuggestionRow =
| { kind: "single"; suggestion: Suggestion }
| { kind: "combined"; intent: SuggestionIntent; suggestions: Suggestion[] };
export interface SuggestionGroupModel {
key: string;
label: string;
apiaryId: string | null;
rows: SuggestionRow[];
}
const OPERATION_KEY = "operation";
const OPERATION_LABEL = "Betrieb";
function compareText(a: string, b: string): number {
return a.localeCompare(b, "de", { numeric: true });
}
function rowsOf(list: readonly Suggestion[]): SuggestionRow[] {
const byIntent = new Map<SuggestionIntent, Suggestion[]>();
for (const suggestion of list) {
byIntent.set(suggestion.intent, [...(byIntent.get(suggestion.intent) ?? []), suggestion]);
}
const rows: SuggestionRow[] = [];
const done = new Set<SuggestionIntent>();
for (const suggestion of list) {
if (done.has(suggestion.intent)) continue;
done.add(suggestion.intent);
const same = byIntent.get(suggestion.intent) ?? [];
rows.push(
same.length > 1
? { kind: "combined", intent: suggestion.intent, suggestions: same }
: { kind: "single", suggestion },
);
}
return rows;
}
/**
* One group per stand (by name), then „Betrieb“ for everything without a
* stand. Inside a group the API's order (rank) is kept; the same intent is
* one row, placed where its first suggestion is.
*/
export function groupSuggestions(list: readonly Suggestion[]): SuggestionGroupModel[] {
const stands = new Map<string, Suggestion[]>();
const operation: Suggestion[] = [];
for (const suggestion of list) {
if (suggestion.apiaryId === null) {
operation.push(suggestion);
} else {
stands.set(suggestion.apiaryId, [...(stands.get(suggestion.apiaryId) ?? []), suggestion]);
}
}
const groups: SuggestionGroupModel[] = [...stands.entries()].map(([apiaryId, members]) => ({
key: apiaryId,
label: members[0].apiaryName ?? "Stand",
apiaryId,
rows: rowsOf(members),
}));
groups.sort((a, b) => compareText(a.label, b.label));
if (operation.length > 0) {
groups.push({ key: OPERATION_KEY, label: OPERATION_LABEL, apiaryId: null, rows: rowsOf(operation) });
}
return groups;
}
export interface RoundTarget {
apiary: { id: string; name: string };
colonies: { id: string; code: string }[];
/** The first inspection of the round. */
href: string;
/** The first inspection without round parameters (no signed-in user to keep the round). */
plainHref: string;
}
/**
* The stand round for due inspections of one stand: the colonies once each,
* in code order. Null without a colony at a stand.
*/
export function roundTarget(suggestions: readonly Suggestion[]): RoundTarget | null {
const colonies = new Map<string, string>();
let apiary: RoundTarget["apiary"] | null = null;
for (const suggestion of suggestions) {
if (suggestion.apiaryId === null || suggestion.colonyId === null) continue;
apiary ??= { id: suggestion.apiaryId, name: suggestion.apiaryName ?? "Stand" };
if (suggestion.apiaryId === apiary.id) {
colonies.set(suggestion.colonyId, suggestion.colonyCode ?? suggestion.colonyId);
}
}
if (apiary === null) return null;
const sorted = [...colonies.entries()]
.map(([id, code]) => ({ id, code }))
.sort((a, b) => compareText(a.code, b.code));
const href = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}&round=${encodeURIComponent(apiary.id)}`;
const plainHref = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}`;
return { apiary, colonies: sorted, href, plainHref };
}