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,152 @@
"use client";
import { useId, useState } from "react";
import Link from "next/link";
import { Check, ChevronDown, Play } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { INTENT_ICONS, INTENT_LABELS } from "@/lib/suggestions/labels";
import { roundTarget, type SuggestionGroupModel } from "@/lib/suggestions/grouping";
import type { Suggestion, SuggestionIntent } from "@/lib/suggestions/types";
import { cn } from "@/lib/utils";
import { SuggestionCard } from "./suggestion-card";
export interface SuggestionGroupProps {
group: SuggestionGroupModel;
/** Suggestions with a running decision; only their cards wait. */
busyIds?: ReadonlySet<string>;
canAccept?: boolean;
canDismiss?: boolean;
/** False without a signed-in user: „Runde starten“ then only opens the first inspection. */
canStartRound?: boolean;
onAccept: (suggestion: Suggestion, dueDate?: string) => void;
onDismiss: (suggestion: Suggestion) => void;
onAcceptAll: (ids: string[]) => void;
/** Called when „Runde starten“ is tapped; the link then opens the first inspection. */
onStartRound: (suggestions: Suggestion[]) => void;
onCleanup?: (suggestion: Suggestion) => void;
}
interface CombinedRowProps extends Omit<SuggestionGroupProps, "group"> {
intent: SuggestionIntent;
suggestions: Suggestion[];
}
/** „n Völker“ when the rows belong to colonies, else „n Vorschläge“. */
function countLabel(suggestions: readonly Suggestion[]): string {
const colonies = new Set(
suggestions.flatMap((suggestion) => (suggestion.colonyId ? [suggestion.colonyId] : [])),
);
if (colonies.size === 0) return `${suggestions.length} Vorschläge`;
return colonies.size === 1 ? "1 Volk" : `${colonies.size} Völker`;
}
function CombinedRow({
intent,
suggestions,
busyIds,
canAccept = true,
canDismiss = true,
canStartRound = true,
...handlers
}: CombinedRowProps) {
const [isOpen, setIsOpen] = useState(false);
const listId = useId();
const Icon = INTENT_ICONS[intent];
const round = intent === "INSPECT" ? roundTarget(suggestions) : null;
const canAcceptAll =
canAccept && suggestions.every((suggestion) => suggestion.mode === "PLAN");
const isBusy = suggestions.some((suggestion) => busyIds?.has(suggestion.id));
return (
<div className="flex flex-col gap-2 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
<div className="flex flex-wrap items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
<Icon className="size-5" aria-hidden />
</span>
<h3 className="min-w-0 flex-1 font-medium">
{INTENT_LABELS[intent]} · {countLabel(suggestions)}
</h3>
{round ? (
<Link
href={canStartRound ? round.href : round.plainHref}
onClick={() => {
if (canStartRound) handlers.onStartRound(suggestions);
}}
className={buttonVariants({ size: "touch" })}
>
<Play aria-hidden />
Runde starten
</Link>
) : canAcceptAll ? (
<Button
size="touch"
disabled={isBusy}
onClick={() => handlers.onAcceptAll(suggestions.map((suggestion) => suggestion.id))}
>
<Check aria-hidden />
Alle übernehmen
</Button>
) : null}
</div>
<Button
variant="ghost"
className="min-h-11 self-start"
aria-expanded={isOpen}
aria-controls={listId}
onClick={() => setIsOpen((open) => !open)}
>
<ChevronDown
aria-hidden
className={cn("transition-transform", isOpen && "rotate-180")}
/>
{isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"}
</Button>
<div id={listId} hidden={!isOpen} className="flex flex-col gap-3">
{isOpen
? suggestions.map((suggestion) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
busy={busyIds?.has(suggestion.id)}
canAccept={canAccept}
canDismiss={canDismiss}
onAccept={handlers.onAccept}
onDismiss={handlers.onDismiss}
onCleanup={handlers.onCleanup}
/>
))
: null}
</div>
</div>
);
}
/** All open suggestions of one stand (or the farm), same intents folded into one row. */
export function SuggestionGroup({ group, ...handlers }: SuggestionGroupProps) {
return (
<section className="flex flex-col gap-3">
<h2 className="font-heading text-lg font-semibold">{group.label}</h2>
{group.rows.map((row) =>
row.kind === "single" ? (
<SuggestionCard
key={row.suggestion.id}
suggestion={row.suggestion}
busy={handlers.busyIds?.has(row.suggestion.id)}
canAccept={handlers.canAccept}
canDismiss={handlers.canDismiss}
onAccept={handlers.onAccept}
onDismiss={handlers.onDismiss}
onCleanup={handlers.onCleanup}
/>
) : (
<CombinedRow
key={`${group.key}:${row.intent}`}
intent={row.intent}
suggestions={row.suggestions}
{...handlers}
/>
),
)}
</section>
);
}