feat(t5): Wächter vollständig, Hinweis Sonnenmodus, Nacharbeiten (T5a A10)
Backend Image / build-and-push (push) Successful in 15m27s
Frontend Image / build-and-push (push) Successful in 23m9s

- Baukasten-Wächter: jede Regel prüft eigene Beispiele (muss fangen, muss
  ignorieren), veraltete Ausnahmen und Ordner-Ausschlüsse schlagen fehl;
  neue Regeln für text-primary ohne -ink, dark:, Schrift unter 13 px
  (Tailwind, SVG, CSS, style, *_SIZE-Konstanten) und Verlaufs-Hintergründe.
  Aufgeteilt in baukasten-guard, touch-target-guard und einen Scan-Helfer.
- Hinweis „Neu: Sonnenmodus für draußen“ auf dem Dashboard, einmal je Gerät,
  „Ausprobieren“ schaltet live um; Fokus geht danach zur Überschrift.
- Varroa-Diagramm: Beschriftungen als HTML mit fester Größe statt SVG-Text.
- Medikamente zeigen die Tabs auch leer; Zahlen in Tabs nicht beim Laden
  oder Fehler; inaktive Tabs mit Hover für die Maus; TabPicker (Auswahl
  unter 640 px) für Automatisierung und Statistik; Rollen-Dialoge breit
  genug für die Rechte-Matrix.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 13:47:59 +02:00
1 parent 355a353671
commit 427969d8b9
42 files changed
+1053 -400

No files matched your search

@@ -43,4 +43,14 @@ describe("ApiariesPage", () => {
expect(await screen.findByText("Heimstand")).toBeInTheDocument();
expect(screen.getByText("Garten")).toBeInTheDocument();
});
it("shows the tabs without a count when the first load failed", async () => {
vi.mocked(fetchApiaries).mockRejectedValueOnce(new Error("kaputt"));
renderWithClient(<ApiariesPage />);
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Aktiv" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument();
expect(screen.queryByText(/\(0\)/)).toBeNull();
});
});
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import {
DropdownMenu,
DropdownMenuContent,
@@ -84,8 +84,8 @@ export default function ApiariesPage() {
<ActiveHistoryTabs
value={tab}
onValueChange={setTab}
activeCount={activeApiaries.length}
historyCount={historyApiaries.length}
activeCount={countOf(apiariesQuery.data, activeApiaries)}
historyCount={countOf(apiariesQuery.data, historyApiaries)}
toolbar={
<Input
className="w-48"
@@ -208,7 +208,8 @@ describe("ColonyVarroaCard trend", () => {
it("labels the date axis and explains the lines in a legend", async () => {
renderCard();
const chart = await screen.findByRole("img");
const axis = within(chart as unknown as HTMLElement);
// The dates sit in an HTML row under the chart (the img names them in its description).
const axis = within(chart.parentElement?.parentElement as HTMLElement);
expect(axis.getByText("01.06.2026")).toBeInTheDocument();
expect(axis.getByText("15.06.2026")).toBeInTheDocument();
await screen.findByText("Rote Grenze");
@@ -217,4 +218,17 @@ describe("ColonyVarroaCard trend", () => {
expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument();
expect(within(legend).getByText("Gelbe Grenze")).toBeInTheDocument();
});
it("chart labels are HTML text at a fixed 13 px, not SVG text that shrinks with a narrow chart", async () => {
const { container } = renderCard();
await screen.findByText("Grenze 10/Tag");
// SVG font sizes are user units: in a 300-unit viewBox on a 360 px phone 13 units are ~12 px.
expect(container.querySelectorAll("svg text")).toHaveLength(0);
for (const text of ["Grenze 10/Tag", "01.06.2026", "15.06.2026"]) {
const label = screen.getByText(text);
expect(label.closest("svg"), text).toBeNull();
expect(label.className.split(/\s+/), text).toContain("text-xs");
expect(label.closest("[aria-hidden='true']"), text).not.toBeNull();
}
});
});
@@ -20,11 +20,12 @@ import { VarroaEditDialog } from "./varroa-edit-dialog";
const CHART_WIDTH = 300;
const CHART_HEIGHT = 136;
const PAD_X = 12;
const PAD_TOP = 12;
const PAD_BOTTOM = 28;
// Room for the HTML limit label (~22 px) above the highest point.
const PAD_TOP = 24;
const PAD_BOTTOM = 12;
const MARKER_RADIUS = 4;
const LABEL_GAP = 4;
const AXIS_LABEL_SIZE = 10;
/** Horizontal inset of the HTML labels, matching PAD_X of the viewBox. */
const PAD_X_PERCENT = `${(PAD_X / CHART_WIDTH) * 100}%`;
const MAX_BAND_MONTHS = 24;
const ONE_HOUR_MS = 60 * 60 * 1000;
@@ -203,78 +204,70 @@ function VarroaTrendChart({
const monthly = monthLimitsText(limits, range);
const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2;
const labelY = CHART_HEIGHT - LABEL_GAP;
return (
<div className="flex flex-col gap-2">
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary-ink"
role="img"
aria-labelledby={titleId}
aria-describedby={descId}
>
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
<desc id={descId}>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
}`}
</desc>
{redPath || yellowPath ? (
<g className="text-muted-foreground" fill="none" stroke="currentColor">
{yellowPath ? (
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
) : null}
{redPath ? (
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
) : null}
</g>
) : null}
{/* Labels are HTML at text-xs: SVG text scales with the viewBox and drops below 13 px. */}
<div className="relative">
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary-ink"
role="img"
aria-labelledby={titleId}
aria-describedby={descId}
>
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
<desc id={descId}>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
}`}
</desc>
{redPath || yellowPath ? (
<g className="text-muted-foreground" fill="none" stroke="currentColor">
{yellowPath ? (
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
) : null}
{redPath ? (
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
) : null}
</g>
) : null}
<line
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={axisY}
y2={axisY}
stroke="currentColor"
strokeWidth={0.5}
className="text-muted-foreground"
/>
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
<circle
key={p.id}
cx={xOf(p.time)}
cy={yOf(p.rate)}
r={MARKER_RADIUS}
fill="currentColor"
className="stroke-background"
strokeWidth={2}
/>
))}
</svg>
{lastLimit ? (
<text
x={CHART_WIDTH - PAD_X}
y={yOf(lastLimit.redAbove) - LABEL_GAP}
textAnchor="end"
fontSize={10}
className="fill-muted-foreground"
<span
aria-hidden="true"
className="absolute -translate-y-full pb-1 text-xs text-muted-foreground"
style={{ top: `${(yOf(lastLimit.redAbove) / CHART_HEIGHT) * 100}%`, right: PAD_X_PERCENT }}
>
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
</text>
</span>
) : null}
<line
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={axisY}
y2={axisY}
stroke="currentColor"
strokeWidth={0.5}
className="text-muted-foreground"
/>
<text x={PAD_X} y={labelY} fontSize={AXIS_LABEL_SIZE} className="fill-muted-foreground">
{formatDate(first.time)}
</text>
<text
x={CHART_WIDTH - PAD_X}
y={labelY}
textAnchor="end"
fontSize={AXIS_LABEL_SIZE}
className="fill-muted-foreground"
>
{formatDate(last.time)}
</text>
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
<circle
key={p.id}
cx={xOf(p.time)}
cy={yOf(p.rate)}
r={MARKER_RADIUS}
fill="currentColor"
className="stroke-background"
strokeWidth={2}
/>
))}
</svg>
</div>
<div aria-hidden="true" className="flex justify-between gap-2" style={{ paddingInline: PAD_X_PERCENT }}>
<span className="text-xs text-muted-foreground">{formatDate(first.time)}</span>
<span className="text-xs text-muted-foreground">{formatDate(last.time)}</span>
</div>
<ChartLegend hasRed={redPath !== ""} hasYellow={yellowPath !== ""} />
</div>
);
@@ -22,7 +22,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import {
DropdownMenu,
DropdownMenuContent,
@@ -108,8 +108,8 @@ export default function ColoniesPage() {
setTab(next);
setStatus("");
}}
activeCount={activeColonies.length}
historyCount={historyColonies.length}
activeCount={countOf(coloniesQuery.data, activeColonies)}
historyCount={countOf(coloniesQuery.data, historyColonies)}
toolbar={
<EnumSelect
ariaLabel="Nach Status filtern"
@@ -288,4 +288,34 @@ describe("DashboardPage – Heute", () => {
expect(header.getAttribute("style")).toBeNull();
expect(container.innerHTML).not.toMatch(/text-white|--gradient/);
});
it("the dashboard shows no white text on honey", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(
todayFixture({
nextStep: { ...stepFixture({ title: "Durchsicht A1" }), colonyId: "c1", colonyCode: "A1" },
}),
);
const { container } = renderPage();
expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary");
const honey = Array.from(container.querySelectorAll<HTMLElement>(".bg-primary"));
expect(honey.length).toBeGreaterThan(0);
for (const element of honey) {
// Text on honey is the dark ink of --primary-foreground, never white (spec 4.4, 9.4:1).
expect(element.className, element.textContent ?? "").toMatch(/(^|\s)text-primary-foreground(\s|$)/);
}
expect(container.innerHTML).not.toMatch(/(^|[\s"])text-white\b/);
});
it("shows the one-time sun notice on the start page (spec 15.4)", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
renderPage();
const notice = await screen.findByRole("complementary", {
name: "Neu: Sonnenmodus für draußen – im Benutzermenü.",
});
expect(within(notice).getByRole("button", { name: "Ausprobieren" })).toBeInTheDocument();
// Closing hands the focus to the greeting, not to <body>.
await userEvent.click(within(notice).getByRole("button", { name: "Schließen" }));
expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 }));
});
});
@@ -24,6 +24,7 @@ import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
import { fetchTasks } from "@/lib/tasks/api";
import { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { AppearanceNotice } from "@/components/appearance-notice";
import { StandNote } from "@/components/offline/stand-note";
import { ComplianceLine } from "@/components/today/compliance-line";
import { TodayPanel } from "@/components/today/today-panel";
@@ -157,7 +158,8 @@ export default function DashboardPage() {
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
<header className="flex flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{/* tabIndex -1: the sun notice hands the focus here when it closes. */}
<h1 tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{greeting(now)}
{firstName ? `, ${firstName}` : ""}
</h1>
@@ -167,6 +169,7 @@ export default function DashboardPage() {
</p>
</header>
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
<AppearanceNotice />
{autopilotError && !isAutopilotSilent ? (
<div
@@ -15,7 +15,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import {
DropdownMenu,
DropdownMenuContent,
@@ -102,8 +102,8 @@ export default function HoneyBatchesPage() {
<ActiveHistoryTabs
value={tab}
onValueChange={setTab}
activeCount={activeBatches.length}
historyCount={historyBatches.length}
activeCount={countOf(honeyBatchesQuery.data, activeBatches)}
historyCount={countOf(honeyBatchesQuery.data, historyBatches)}
>
{{ active: listOf("active"), history: listOf("history") }}
</ActiveHistoryTabs>
@@ -88,6 +88,18 @@ describe("MedicinesPage", () => {
expect(screen.getByText("Oxalsäure")).toBeInTheDocument();
});
it("an empty catalogue keeps the Aktiv (0) / Historie (0) tabs like every other list", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([]);
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
renderPage();
const active = await screen.findByRole("tab", { name: "Aktiv (0)" });
expect(active).toHaveAttribute("aria-selected", "true");
expect(await within(screen.getByRole("tabpanel")).findByText("Aus deinen Behandlungen übernehmen?")).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "Historie (0)" }));
expect(within(screen.getByRole("tabpanel")).getByText("Keine deaktivierten Mittel")).toBeInTheDocument();
});
it("applying a suggestion opens the form prefilled with the values of the latest treatment", async () => {
vi.mocked(fetchMedicines).mockResolvedValue([]);
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchMedicines } from "@/lib/medicines/api";
import type { MedicineView } from "@/lib/medicines/types";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
@@ -35,9 +35,19 @@ export default function MedicinesPage() {
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
: "";
const listOf = (bucket: EntityLifecycleTab) => {
const listOf = (bucket: EntityLifecycleTab) => (
<QueryState query={medicinesQuery} what="Tierarzneimittel">
{() => bucketList(bucket)}
</QueryState>
);
const bucketList = (bucket: EntityLifecycleTab) => {
const shown = bucket === "active" ? active : inactive;
if (shown.length > 0) return <MedicineGrid medicines={shown} />;
// An empty catalogue starts from the beekeeper's own treatments.
if (bucket === "active" && medicines.length === 0) {
return <MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />;
}
return (
<EmptyState
icon={Pill}
@@ -63,27 +73,19 @@ export default function MedicinesPage() {
/>
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
<QueryState query={medicinesQuery} what="Tierarzneimittel">
{() =>
medicines.length === 0 ? (
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
) : (
<ActiveHistoryTabs
value={tab}
onValueChange={setTab}
activeCount={active.length}
historyCount={inactive.length}
toolbar={
<p aria-live="polite" className="text-sm text-muted-foreground">
{countText}
</p>
}
>
{{ active: listOf("active"), history: listOf("history") }}
</ActiveHistoryTabs>
)
<ActiveHistoryTabs
value={tab}
onValueChange={setTab}
activeCount={countOf(medicinesQuery.data, active)}
historyCount={countOf(medicinesQuery.data, inactive)}
toolbar={
<p aria-live="polite" className="text-sm text-muted-foreground">
{countText}
</p>
}
</QueryState>
>
{{ active: listOf("active"), history: listOf("history") }}
</ActiveHistoryTabs>
</main>
);
}
@@ -14,7 +14,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
export default function ProductsPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active");
@@ -76,8 +76,8 @@ export default function ProductsPage() {
<ActiveHistoryTabs
value={tab}
onValueChange={setTab}
activeCount={activeProducts.length}
historyCount={historyProducts.length}
activeCount={countOf(productsQuery.data, activeProducts)}
historyCount={countOf(productsQuery.data, historyProducts)}
>
{{ active: listOf("active"), history: listOf("history") }}
</ActiveHistoryTabs>
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { EnumSelect } from "@/components/form/enum-select";
export default function QueensPage() {
@@ -68,8 +68,8 @@ export default function QueensPage() {
setTab(next);
setStatus("");
}}
activeCount={activeQueens.length}
historyCount={historyQueens.length}
activeCount={countOf(queensQuery.data, activeQueens)}
historyCount={countOf(queensQuery.data, historyQueens)}
toolbar={
<EnumSelect
ariaLabel="Nach Status filtern"
@@ -3,7 +3,8 @@
import { Suspense, type ComponentType } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabPicker } from "@/components/tab-picker";
import { WebhooksTab } from "./webhooks-tab";
import { TaskTemplatesTab } from "./task-templates-tab";
import { LabelTemplatesTab } from "./label-templates-tab";
@@ -32,6 +33,9 @@ const TABS: { value: Tab; label: string; Content: ComponentType }[] = [
{ value: "webhooks", label: "Webhooks", Content: WebhooksTab },
];
const TAB_VALUES = TABS.map((entry) => entry.value);
const TAB_LABELS = Object.fromEntries(TABS.map((entry) => [entry.value, entry.label])) as Record<Tab, string>;
function isTab(value: unknown): value is Tab {
return TABS.some((tab) => tab.value === value);
}
@@ -60,13 +64,14 @@ function AutomationPageContent() {
if (isTab(next)) setTab(next);
}}
>
<TabsList aria-label="Bereiche der Automatisierung">
{TABS.map((entry) => (
<TabsTrigger key={entry.value} value={entry.value}>
{entry.label}
</TabsTrigger>
))}
</TabsList>
<TabPicker
value={tab}
onChange={setTab}
labels={TAB_LABELS}
values={TAB_VALUES}
listLabel="Bereiche der Automatisierung"
selectLabel="Bereich der Automatisierung wählen"
/>
{TABS.map(({ value, Content }) => (
<TabsContent key={value} value={value}>
<Content />
@@ -11,7 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { CreateTaskTemplateDialog } from "./create-task-template-dialog";
import { EditTaskTemplateDialog } from "./edit-task-template-dialog";
@@ -116,8 +116,8 @@ export function TaskTemplatesTab() {
<ActiveHistoryTabs
value={tab}
onValueChange={setTab}
activeCount={activeTemplates.length}
historyCount={historyTemplates.length}
activeCount={countOf(templates, activeTemplates)}
historyCount={countOf(templates, historyTemplates)}
toolbar={<CreateTaskTemplateDialog />}
toolbarAlign="end"
>
@@ -43,6 +43,23 @@ describe("both settings pages use Tabs directly", () => {
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
});
it("Automatisierung: on a phone a select replaces the seven tabs and keeps the ?tab= contract", async () => {
search = "tab=api-keys";
const user = userEvent.setup();
render(<AutomationPage />);
// Below sm the tab bar would wrap into five rows; the same choice is a select there.
const picker = screen.getByRole("combobox", { name: "Bereich der Automatisierung wählen" });
expect(picker.className.split(/\s+/)).toContain("sm:hidden");
expect(picker).toHaveTextContent("API-Schlüssel");
const list = screen.getByRole("tablist", { hidden: true });
expect(list.className.split(/\s+/)).toEqual(expect.arrayContaining(["hidden", "sm:inline-flex"]));
await user.click(picker);
await user.click(await screen.findByRole("option", { name: "Webhooks" }));
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
});
it("Automatisierung: an unknown ?tab= falls back to Vorschläge", () => {
search = "tab=unknown";
render(<AutomationPage />);
@@ -78,7 +78,7 @@ export function CreateRoleDialog() {
}}
>
<Button onClick={() => setOpen(true)}>+ Neue Rolle</Button>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Neue Rolle</DialogTitle>
</DialogHeader>
@@ -115,7 +115,7 @@ interface EditRoleDialogProps {
export function EditRoleDialog({ role, onClose }: EditRoleDialogProps) {
return (
<Dialog open={role !== null} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Rolle bearbeiten{role ? `: ${role.name}` : ""}</DialogTitle>
</DialogHeader>
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -45,4 +47,13 @@ describe("PermissionMatrix", () => {
expect(box).toHaveAccessibleDescription(expect.stringContaining(hint.textContent ?? "x"));
}
});
it.each(["create-role-dialog.tsx", "edit-role-dialog.tsx"])(
"%s gives the matrix the wide dialog on a wide screen (sm:max-w-2xl beats the 384 px default)",
(file) => {
const text = readFileSync(join(__dirname, file), "utf8");
const content = /<DialogContent className="([^"]*)"/.exec(text)?.[1] ?? "";
expect(content.split(/\s+/)).toContain("sm:max-w-2xl");
},
);
});
@@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { CreateRoleDialog } from "./create-role-dialog";
import { EditRoleDialog } from "./edit-role-dialog";
@@ -160,8 +160,8 @@ export function RolesTab() {
<ActiveHistoryTabs
value={tab}
onValueChange={setTab}
activeCount={activeRoles.length}
historyCount={historyRoles.length}
activeCount={countOf(roles, activeRoles)}
historyCount={countOf(roles, historyRoles)}
toolbar={<CreateRoleDialog />}
toolbarAlign="end"
>
@@ -63,4 +63,11 @@ describe("StatisticsPage", () => {
expect(list).toHaveAttribute("data-variant", "segment");
expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true");
});
it("on a phone the select says what it picks", () => {
vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {}));
renderWithClient(<StatisticsPage />);
expect(screen.getByRole("combobox", { name: "Auswertung wählen" })).toHaveTextContent("Völker");
expect(screen.getByRole("tablist", { hidden: true })).toHaveAccessibleName("Auswertungen");
});
});
@@ -18,8 +18,8 @@ import { StatBarList } from "@/components/stat-bar-list";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumSelect } from "@/components/form/enum-select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabPicker } from "@/components/tab-picker";
import { keysOf } from "@/lib/keys-of";
const TAB_LABELS = {
@@ -345,19 +345,6 @@ function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string>
);
}
/** On phones the tab bar does not fit - the same choice as a select. */
function TabPicker({ value, onChange }: { value: StatsTab; onChange: (value: StatsTab) => void }) {
return (
<EnumSelect
ariaLabel="Bereich wählen"
labels={TAB_LABELS}
value={value}
onChange={onChange}
className="sm:hidden"
/>
);
}
export default function StatisticsPage() {
const [tab, setTab] = useState<StatsTab>("colonies");
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
@@ -375,14 +362,14 @@ export default function StatisticsPage() {
/>
<Tabs value={tab} onValueChange={(next) => setTab(toStatsTab(next) ?? tab)}>
<TabPicker value={tab} onChange={setTab} />
<TabsList className="hidden sm:inline-flex">
{TABS.map((entry) => (
<TabsTrigger key={entry} value={entry}>
{TAB_LABELS[entry]}
</TabsTrigger>
))}
</TabsList>
<TabPicker
value={tab}
onChange={setTab}
labels={TAB_LABELS}
values={TABS}
listLabel="Auswertungen"
selectLabel="Auswertung wählen"
/>
<TabsContent value="colonies">
<ColonyStatsPanel />
</TabsContent>
@@ -12,7 +12,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { Badge } from "@/components/ui/badge";
import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
import { CreateStockItemDialog } from "./create-stock-item-dialog";
@@ -65,8 +65,8 @@ export default function StoragePage() {
heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>}
value={containerTab}
onValueChange={setContainerTab}
activeCount={activeContainers.length}
historyCount={historyContainers.length}
activeCount={countOf(containersQuery.data, activeContainers)}
historyCount={countOf(containersQuery.data, historyContainers)}
>
{{ active: containersOf("active"), history: containersOf("history") }}
</ActiveHistoryTabs>
@@ -14,7 +14,7 @@ import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { EnumSelect } from "@/components/form/enum-select";
import { TaskEditDialog } from "./task-edit-dialog";
@@ -136,8 +136,8 @@ export default function TasksPage() {
<ActiveHistoryTabs
value={tab}
onValueChange={setTab}
activeCount={activeTasks.length}
historyCount={historyTasks.length}
activeCount={countOf(tasks, activeTasks)}
historyCount={countOf(tasks, historyTasks)}
toolbar={
<>
<EnumSelect
@@ -82,7 +82,7 @@ function Stat({ icon: Icon, label, value }: StatProps) {
<div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center">
<Icon className="size-4 text-primary-ink" />
<span className="text-sm font-semibold">{value}</span>
<span className="text-[0.7rem] text-muted-foreground">{label}</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
);
}