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