feat(t5): Bienenjahr-Rad auf dem Dashboard (T5b B7)
- Abschnitt „Bienenjahr“ nach „Im Blick behalten“: Rad mit Zeiger heute, sechs Abschnitten, Fenstern (Varroa schraffiert), Aufgaben-Punkten je Woche, Mitte als Größen-Container (höchstens drei Zeilen), Zeile „Jetzt … · Danach …“, Hinweise und Abweichungen darunter, drei Tabellen mit Beschriftung, Link „Zum Kalender“. - Text als HTML, Tokens, Forced Colors mit eigenem Muster, keine Animation; nutzt die vorhandenen Queries des Dashboards. - Überblick-Kacheln und Saison-Chips im Heute-Panel entfallen (Spec 9.5); späte Blüte als lateStart im Modell. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
3ea285bce5
commit
bb3f79e55f
11 files changed
+994
-117
No files matched your search
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import DashboardPage from "./page";
|
import DashboardPage from "./page";
|
||||||
import { fetchAutopilotToday } from "@/lib/autopilot/api";
|
import { fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||||
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
|
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
|
||||||
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
import { fetchDashboardAlerts } from "@/lib/dashboard/api";
|
||||||
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
@@ -17,7 +17,6 @@ vi.mock("@/lib/auth/auth-context", () => ({
|
|||||||
useCurrentUserId: () => "user-1",
|
useCurrentUserId: () => "user-1",
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/dashboard/api", () => ({
|
vi.mock("@/lib/dashboard/api", () => ({
|
||||||
fetchDashboardSummary: vi.fn(),
|
|
||||||
fetchDashboardAlerts: vi.fn(),
|
fetchDashboardAlerts: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/tasks/api", () => ({
|
vi.mock("@/lib/tasks/api", () => ({
|
||||||
@@ -73,14 +72,6 @@ describe("DashboardPage – Heute", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
apiariesActive: 1,
|
|
||||||
coloniesActive: 2,
|
|
||||||
queensActive: 2,
|
|
||||||
openTasks: 1,
|
|
||||||
honeyBatchesTotal: 0,
|
|
||||||
stockItemsLowCount: 0,
|
|
||||||
} as never);
|
|
||||||
vi.mocked(fetchDashboardAlerts).mockResolvedValue([]);
|
vi.mocked(fetchDashboardAlerts).mockResolvedValue([]);
|
||||||
vi.mocked(fetchTasks).mockResolvedValue([overdueTask()]);
|
vi.mocked(fetchTasks).mockResolvedValue([overdueTask()]);
|
||||||
vi.mocked(fetchAutopilotToday).mockReset();
|
vi.mocked(fetchAutopilotToday).mockReset();
|
||||||
@@ -114,7 +105,7 @@ describe("DashboardPage – Heute", () => {
|
|||||||
expect(await within(fallback).findByText("Stock prüfen")).toBeInTheDocument();
|
expect(await within(fallback).findByText("Stock prüfen")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a „Durchsicht fällig“ card per apiary, the suggestion count and the season per apiary", async () => {
|
it("shows a „Durchsicht fällig“ card per apiary and the suggestion count", async () => {
|
||||||
vi.mocked(fetchTasks).mockResolvedValue([]);
|
vi.mocked(fetchTasks).mockResolvedValue([]);
|
||||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(
|
||||||
todayFixture({
|
todayFixture({
|
||||||
@@ -137,7 +128,49 @@ describe("DashboardPage – Heute", () => {
|
|||||||
await screen.findByRole("heading", { name: /Durchsicht fällig · Obstwiese/ }),
|
await screen.findByRole("heading", { name: /Durchsicht fällig · Obstwiese/ }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: /Vorschläge 4/ })).toHaveAttribute("href", "/suggestions");
|
expect(screen.getByRole("link", { name: /Vorschläge 4/ })).toHaveAttribute("href", "/suggestions");
|
||||||
expect(screen.getByText("Obstwiese: Schwarmzeit")).toBeInTheDocument();
|
});
|
||||||
|
|
||||||
|
it("the dashboard no longer shows the season chip row", async () => {
|
||||||
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(
|
||||||
|
todayFixture({
|
||||||
|
seasons: [
|
||||||
|
{ apiaryId: "a-1", apiaryName: "Obstwiese", segment: "SCHWARMZEIT", segmentSince: "2026-04-15" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
// The season moved into the centre of the bee-year wheel (spec 9.3).
|
||||||
|
const wheel = (await screen.findByRole("heading", { name: "Bienenjahr" })).closest("section")!;
|
||||||
|
expect(await within(wheel).findByText(/^Jetzt: Schwarmzeit seit 15\.04\./)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Obstwiese: Schwarmzeit")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("list", { name: /Saison/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the dashboard has no count tiles and no white text on honey", async () => {
|
||||||
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||||
|
vi.mocked(fetchDashboardAlerts).mockResolvedValue([
|
||||||
|
{ type: "LOW_STOCK", message: "Gläser unter Minimum", relatedType: "StockItem", relatedId: "s1" },
|
||||||
|
]);
|
||||||
|
const { container } = renderPage();
|
||||||
|
|
||||||
|
const wheel = await screen.findByRole("heading", { level: 2, name: "Bienenjahr" });
|
||||||
|
expect(screen.queryByRole("heading", { name: "Überblick" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("link", { name: /Völker|Honigchargen|unter Minimum/ })).not.toBeInTheDocument();
|
||||||
|
// „Bienenjahr“ is the last section, after „Im Blick behalten“ (spec 9.3).
|
||||||
|
const alerts = await screen.findByRole("heading", { name: "Im Blick behalten" });
|
||||||
|
expect(alerts.compareDocumentPosition(wheel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(container.innerHTML).not.toMatch(/(^|[\s"])text-white\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the dashboard still works without T4 data", async () => {
|
||||||
|
vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("Stock prüfen")).toBeInTheDocument();
|
||||||
|
const wheel = (await screen.findByRole("heading", { name: "Bienenjahr" })).closest("section")!;
|
||||||
|
expect(within(wheel).getByRole("img", { name: /Bienenjahr \d{4}/ })).toBeInTheDocument();
|
||||||
|
expect(within(wheel).getByText(/^Jetzt: .* \(Kalender\)/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back silently for a network error and a 403, but says so for other errors and allows a retry", async () => {
|
it("falls back silently for a network error and a 403, but says so for other errors and allows a retry", async () => {
|
||||||
@@ -211,7 +244,8 @@ describe("DashboardPage – Heute", () => {
|
|||||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
await screen.findByRole("heading", { name: "Überblick" });
|
// Wait for the data, not just the wheel (it renders from the calendar at once).
|
||||||
|
await screen.findByText("Stock prüfen");
|
||||||
expect(screen.queryByText(/^Stand:/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/^Stand:/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Crown,
|
Crown,
|
||||||
Droplet,
|
|
||||||
ListTodo,
|
ListTodo,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MapPin,
|
|
||||||
Layers,
|
|
||||||
PackageSearch,
|
PackageSearch,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sprout,
|
Sprout,
|
||||||
@@ -19,13 +16,14 @@ import { ApiError } from "@/lib/auth/api-client";
|
|||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||||
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
|
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||||
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
import { fetchDashboardAlerts } from "@/lib/dashboard/api";
|
||||||
import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
|
import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
|
||||||
import { fetchTasks } from "@/lib/tasks/api";
|
import { fetchTasks } from "@/lib/tasks/api";
|
||||||
import { tasksForToday } from "@/lib/tasks/today";
|
import { tasksForToday } from "@/lib/tasks/today";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AppearanceNotice } from "@/components/appearance-notice";
|
import { AppearanceNotice } from "@/components/appearance-notice";
|
||||||
import { StandNote } from "@/components/offline/stand-note";
|
import { StandNote } from "@/components/offline/stand-note";
|
||||||
|
import { YearWheel } from "@/components/season/year-wheel";
|
||||||
import { ComplianceLine } from "@/components/today/compliance-line";
|
import { ComplianceLine } from "@/components/today/compliance-line";
|
||||||
import { TodayPanel } from "@/components/today/today-panel";
|
import { TodayPanel } from "@/components/today/today-panel";
|
||||||
|
|
||||||
@@ -49,14 +47,6 @@ function todaySummary(count: number): string {
|
|||||||
return `Heute stehen ${count} Sachen an – eine nach der anderen.`;
|
return `Heute stehen ${count} Sachen an – eine nach der anderen.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatTile {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
icon: LucideIcon;
|
|
||||||
href: string;
|
|
||||||
attention?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALERT_META: Record<
|
const ALERT_META: Record<
|
||||||
DashboardAlertType,
|
DashboardAlertType,
|
||||||
{ label: string; icon: LucideIcon; className: string }
|
{ label: string; icon: LucideIcon; className: string }
|
||||||
@@ -91,11 +81,6 @@ const ALERT_META: Record<
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [now] = useState(() => new Date());
|
const [now] = useState(() => new Date());
|
||||||
const summaryQuery = useQuery({
|
|
||||||
queryKey: ["dashboard", "summary"],
|
|
||||||
queryFn: fetchDashboardSummary,
|
|
||||||
});
|
|
||||||
const { data: summary } = summaryQuery;
|
|
||||||
const alertsQuery = useQuery({
|
const alertsQuery = useQuery({
|
||||||
queryKey: ["dashboard", "alerts"],
|
queryKey: ["dashboard", "alerts"],
|
||||||
queryFn: fetchDashboardAlerts,
|
queryFn: fetchDashboardAlerts,
|
||||||
@@ -124,37 +109,15 @@ export default function DashboardPage() {
|
|||||||
const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK");
|
const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK");
|
||||||
const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null;
|
const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null;
|
||||||
// Offline everything shown is from the cache: the oldest part sets the „Stand“.
|
// Offline everything shown is from the cache: the oldest part sets the „Stand“.
|
||||||
const loadedAt = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery]
|
const loadedAt = [alertsQuery, tasksQuery, autopilotQuery]
|
||||||
.map((query) => query.dataUpdatedAt)
|
.map((query) => query.dataUpdatedAt)
|
||||||
.filter((updatedAt) => updatedAt > 0);
|
.filter((updatedAt) => updatedAt > 0);
|
||||||
const hasStaleData = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery].some(
|
const hasStaleData = [alertsQuery, tasksQuery, autopilotQuery].some(
|
||||||
(query) => query.isError && query.data !== undefined,
|
(query) => query.isError && query.data !== undefined,
|
||||||
);
|
);
|
||||||
const standAt = loadedAt.length > 0 ? Math.min(...loadedAt) : 0;
|
const standAt = loadedAt.length > 0 ? Math.min(...loadedAt) : 0;
|
||||||
const firstName = user?.name.trim().split(/\s+/)[0];
|
const firstName = user?.name.trim().split(/\s+/)[0];
|
||||||
|
|
||||||
const tiles: StatTile[] = summary
|
|
||||||
? [
|
|
||||||
{ label: "Bienenstände", value: summary.apiariesActive, icon: MapPin, href: "/apiaries" },
|
|
||||||
{ label: "Völker", value: summary.coloniesActive, icon: Layers, href: "/colonies" },
|
|
||||||
{ label: "Königinnen", value: summary.queensActive, icon: Crown, href: "/queens" },
|
|
||||||
{ label: "Offene Aufgaben", value: summary.openTasks, icon: ListTodo, href: "/tasks" },
|
|
||||||
{
|
|
||||||
label: "Honigchargen",
|
|
||||||
value: summary.honeyBatchesTotal,
|
|
||||||
icon: Droplet,
|
|
||||||
href: "/honey-batches",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "unter Minimum",
|
|
||||||
value: summary.stockItemsLowCount,
|
|
||||||
icon: AlertTriangle,
|
|
||||||
href: "/storage",
|
|
||||||
attention: summary.stockItemsLowCount > 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
|
<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">
|
<header className="flex flex-col gap-1">
|
||||||
@@ -235,37 +198,7 @@ export default function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{tiles.length > 0 ? (
|
<YearWheel />
|
||||||
<section aria-labelledby="overview-heading" className="flex flex-col gap-2">
|
|
||||||
<h2 id="overview-heading" className="font-heading text-lg font-semibold tracking-tight">
|
|
||||||
Überblick
|
|
||||||
</h2>
|
|
||||||
<ul className="grid grid-cols-2 gap-x-4 gap-y-1 rounded-2xl border border-border/60 bg-card p-2 sm:grid-cols-3">
|
|
||||||
{tiles.map((tile) => {
|
|
||||||
const Icon = tile.icon;
|
|
||||||
return (
|
|
||||||
<li key={tile.label}>
|
|
||||||
<Link
|
|
||||||
href={tile.href}
|
|
||||||
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={`size-5 shrink-0 ${tile.attention ? "text-warning-ink" : "text-primary-ink"}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span className="font-heading text-lg font-semibold tabular-nums">
|
|
||||||
{tile.value}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
|
||||||
{tile.label}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { formatDayMonth } from "@/lib/autopilot/format";
|
||||||
|
import type { SeasonOfApiary } from "@/lib/autopilot/types";
|
||||||
|
import type { YearWheelModel } from "@/lib/season/year-wheel";
|
||||||
|
import { SEGMENT_LABELS } from "@/components/autopilot/season-chip";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
WINDOW_LABELS,
|
||||||
|
nextFourWeeks,
|
||||||
|
segmentEndText,
|
||||||
|
segmentStartText,
|
||||||
|
sourceText,
|
||||||
|
} from "./year-wheel-text";
|
||||||
|
|
||||||
|
const CAPTION = "caption-top px-3 py-2 text-left text-sm font-medium";
|
||||||
|
|
||||||
|
interface YearWheelTableProps {
|
||||||
|
model: YearWheelModel;
|
||||||
|
/** The apiaries' seasons; each gets a column when they do not all agree. */
|
||||||
|
seasons: SeasonOfApiary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wheel as tables (spec 9.4): segments, work windows and the tasks of the next four weeks. */
|
||||||
|
export function YearWheelTable({ model, seasons }: YearWheelTableProps) {
|
||||||
|
const apiaries = model.deviations.length > 0 ? seasons : [];
|
||||||
|
const weeks = nextFourWeeks(model);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Table>
|
||||||
|
<caption className={CAPTION}>Abschnitte des Bienenjahrs</caption>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Abschnitt</TableHead>
|
||||||
|
<TableHead>von</TableHead>
|
||||||
|
<TableHead>bis</TableHead>
|
||||||
|
<TableHead>Quelle</TableHead>
|
||||||
|
{apiaries.map((season) => (
|
||||||
|
<TableHead key={season.apiaryId}>{season.apiaryName}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{model.segments.map((segment, index) => (
|
||||||
|
<TableRow key={segment.key}>
|
||||||
|
<TableHead scope="row">{SEGMENT_LABELS[segment.key]}</TableHead>
|
||||||
|
<TableCell className="tabular-nums">{segmentStartText(segment)}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{segmentEndText(model, index)}</TableCell>
|
||||||
|
<TableCell>{segment.current ? sourceText(model) : "Kalender"}</TableCell>
|
||||||
|
{apiaries.map((season) => (
|
||||||
|
<TableCell key={season.apiaryId}>
|
||||||
|
{season.segment !== segment.key
|
||||||
|
? "–"
|
||||||
|
: season.segmentSince
|
||||||
|
? `jetzt, seit ${formatDayMonth(season.segmentSince)}`
|
||||||
|
: "jetzt"}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<Table>
|
||||||
|
<caption className={CAPTION}>Arbeitsfenster</caption>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Fenster</TableHead>
|
||||||
|
<TableHead>von</TableHead>
|
||||||
|
<TableHead>bis</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{model.windows.map((window) => (
|
||||||
|
<TableRow key={window.key}>
|
||||||
|
<TableHead scope="row">{WINDOW_LABELS[window.key]}</TableHead>
|
||||||
|
<TableCell className="tabular-nums">{formatDayMonth(window.from)}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{formatDayMonth(window.to)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<Table>
|
||||||
|
<caption className={CAPTION}>Aufgaben je Woche</caption>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Woche</TableHead>
|
||||||
|
{weeks.map((week) => (
|
||||||
|
<TableHead key={week.weekStart}>{`ab ${formatDayMonth(week.weekStart)}`}</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead>überfällig</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead scope="row">Aufgaben je Woche</TableHead>
|
||||||
|
{weeks.map((week) => (
|
||||||
|
<TableCell key={week.weekStart} className="tabular-nums">
|
||||||
|
{week.count}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell className="tabular-nums">{model.overdueCount}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { formatDayMonth } from "@/lib/autopilot/format";
|
||||||
|
import type { SeasonNote } from "@/lib/phenology/types";
|
||||||
|
import { phaseSourceText } from "@/lib/phenology/labels";
|
||||||
|
import type { YearWheelModel, YearWheelSegment, YearWheelWindow, YearWheelWindowKey } from "@/lib/season/year-wheel";
|
||||||
|
import { SEGMENT_LABELS } from "@/components/autopilot/season-chip";
|
||||||
|
|
||||||
|
export const WINDOW_LABELS: Record<YearWheelWindowKey, string> = {
|
||||||
|
TRACHT: "Tracht",
|
||||||
|
VARROA_SUMMER: "Varroa-Sommerbehandlung",
|
||||||
|
EINWINTERUNG: "Einwinterung",
|
||||||
|
VARROA_WINTER: "Varroa-Winterbehandlung",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Short enough for the wheel's centre at 320 px in sun (16 px): „Einwinterung“ is the longest. */
|
||||||
|
const CENTRE_WINDOW_LABELS: Record<YearWheelWindowKey, string> = {
|
||||||
|
TRACHT: "Tracht",
|
||||||
|
VARROA_SUMMER: "Varroa",
|
||||||
|
EINWINTERUNG: "Einwinterung",
|
||||||
|
VARROA_WINTER: "Varroa",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Lower case: the note follows the window in the line below the wheel. */
|
||||||
|
const NOTE_TEXT: Record<NonNullable<SeasonNote>, string> = {
|
||||||
|
NO_COORDINATES: "ohne Koordinaten",
|
||||||
|
WEATHER_GAPS: "Wetterdaten lückenhaft",
|
||||||
|
WEATHER_STALE: "Wetterdaten veraltet",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
const addDays = (date: string, days: number): string =>
|
||||||
|
new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
/** The Monday of the week of `date` (YYYY-MM-DD). */
|
||||||
|
export function mondayOf(date: string): string {
|
||||||
|
return addDays(date, -((new Date(`${date}T00:00:00Z`).getUTCDay() + 6) % 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The four weeks from this one on, with their open tasks (spec 9.4). */
|
||||||
|
export function nextFourWeeks(model: YearWheelModel): { weekStart: string; count: number }[] {
|
||||||
|
const monday = mondayOf(model.today);
|
||||||
|
return [0, 1, 2, 3].map((week) => {
|
||||||
|
const weekStart = addDays(monday, week * 7);
|
||||||
|
return { weekStart, count: model.taskWeeks.find((entry) => entry.weekStart === weekStart)?.count ?? 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „ab morgen, noch offen“ for a late bloom, „ca. 15.11.“ for a calendar boundary, else „15.09.“. */
|
||||||
|
export function segmentStartText(segment: YearWheelSegment): string {
|
||||||
|
if (segment.lateStart) return "ab morgen, noch offen";
|
||||||
|
return `${segment.estimated ? "ca. " : ""}${formatDayMonth(segment.from)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the segment at `index` ends: the next one's start, „noch offen“ before a late bloom. */
|
||||||
|
export function segmentEndText(model: YearWheelModel, index: number): string {
|
||||||
|
const next = model.segments[index + 1];
|
||||||
|
if (next?.lateStart) return "noch offen";
|
||||||
|
return `ca. ${formatDayMonth(model.segments[index].to)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceText(model: YearWheelModel): string {
|
||||||
|
return phaseSourceText(model.segments[0].source, model.reference?.reportedOn ?? null) ?? "Kalender";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function windowsInOrder(model: YearWheelModel): YearWheelWindow[] {
|
||||||
|
return [...model.windows, ...model.nextWindows];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runningWindow(model: YearWheelModel): YearWheelWindow | null {
|
||||||
|
return windowsInOrder(model).find((window) => window.from <= model.today && model.today < window.to) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function comingWindow(model: YearWheelModel): YearWheelWindow | null {
|
||||||
|
return windowsInOrder(model).find((window) => window.from >= model.today && window !== runningWindow(model)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The centre's lines under the segment; the running window as name (wide wheel) or end (narrow). */
|
||||||
|
export function centreLines(model: YearWheelModel): { since: string; window: { name: string; end: string } | null } {
|
||||||
|
const running = runningWindow(model);
|
||||||
|
return {
|
||||||
|
since: `seit ${formatDayMonth(model.segments[0].from)}`,
|
||||||
|
window: running ? { name: CENTRE_WINDOW_LABELS[running.key], end: `bis ${formatDayMonth(running.to)}` } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Südhang: noch Frühjahr“, or how many apiaries deviate. */
|
||||||
|
function deviationText(model: YearWheelModel): string | null {
|
||||||
|
if (model.deviations.length > 1) return `${model.deviations.length} Stände weichen ab – siehe Tabelle`;
|
||||||
|
const [deviation] = model.deviations;
|
||||||
|
if (!deviation) return null;
|
||||||
|
// A segment begun before the reference one is behind („noch“), a later one ahead („schon“).
|
||||||
|
const word = deviation.since < model.segments[0].from ? "noch" : "schon";
|
||||||
|
return `${deviation.apiaryName}: ${word} ${SEGMENT_LABELS[deviation.segment]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Below the wheel: the running window in full, a weak estimate and deviating apiaries. */
|
||||||
|
export function detailLine(model: YearWheelModel): string | null {
|
||||||
|
const running = runningWindow(model);
|
||||||
|
const note = model.reference?.note;
|
||||||
|
const parts = [
|
||||||
|
running ? `${WINDOW_LABELS[running.key]} bis ca. ${formatDayMonth(running.to)}` : null,
|
||||||
|
note ? NOTE_TEXT[note] : null,
|
||||||
|
deviationText(model),
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Jetzt: Herbst seit 15.09. (Kalender) · Danach: Winterruhe ab ca. 15.11.“ */
|
||||||
|
export function nowAndNext(model: YearWheelModel): string {
|
||||||
|
const [current, next] = model.segments;
|
||||||
|
const start = segmentStartText(next);
|
||||||
|
const nextText = next.lateStart ? start : `ab ${start}`;
|
||||||
|
return `Jetzt: ${SEGMENT_LABELS[current.key]} seit ${formatDayMonth(current.from)} (${sourceText(model)}) · Danach: ${SEGMENT_LABELS[next.key]} ${nextText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourcePhrase(model: YearWheelModel): string {
|
||||||
|
const source = model.segments[0].source;
|
||||||
|
return source === "CALENDAR" ? "nach Kalender" : sourceText(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date like „20.09.“ already ends the sentence. */
|
||||||
|
const sentence = (text: string): string => (text.endsWith(".") ? text : `${text}.`);
|
||||||
|
|
||||||
|
/** The SVG description (spec 9.4). */
|
||||||
|
export function wheelDescription(model: YearWheelModel): string {
|
||||||
|
const current = model.segments[0];
|
||||||
|
const running = runningWindow(model);
|
||||||
|
const coming = comingWindow(model);
|
||||||
|
const windowText = (window: YearWheelWindow): string =>
|
||||||
|
`${WINDOW_LABELS[window.key]} ${formatDayMonth(window.from)} bis ${formatDayMonth(window.to)}`;
|
||||||
|
const weeks = nextFourWeeks(model).reduce((sum, week) => sum + week.count, 0);
|
||||||
|
const tasks =
|
||||||
|
weeks + model.overdueCount === 0
|
||||||
|
? "Keine offenen Aufgaben mit Termin in den nächsten 4 Wochen."
|
||||||
|
: `Offene Aufgaben mit Termin: ${weeks} in den nächsten 4 Wochen${model.overdueCount > 0 ? `, ${model.overdueCount} überfällig` : ""}.`;
|
||||||
|
return [
|
||||||
|
sentence(`Heute, ${formatDayMonth(model.today)}: ${SEGMENT_LABELS[current.key]} seit ${formatDayMonth(current.from)} ${sourcePhrase(model)}`),
|
||||||
|
running ? sentence(`${WINDOW_LABELS[running.key]} läuft bis etwa ${formatDayMonth(running.to)}`) : null,
|
||||||
|
coming ? sentence(`${running ? "Danach" : "Als Nächstes"} ${windowText(coming)}`) : null,
|
||||||
|
tasks,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||||
|
import { todayFixture } from "@/lib/autopilot/step-fixtures";
|
||||||
|
import type { SeasonOfApiary } from "@/lib/autopilot/types";
|
||||||
|
import { fetchTasks } from "@/lib/tasks/api";
|
||||||
|
import type { Task } from "@/lib/tasks/types";
|
||||||
|
import { YearWheel } from "./year-wheel";
|
||||||
|
|
||||||
|
vi.mock("@/lib/tasks/api", () => ({ fetchTasks: vi.fn() }));
|
||||||
|
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
|
||||||
|
fetchAutopilotToday: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function task(id: string, dueDate: string): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: `Aufgabe ${id}`,
|
||||||
|
description: null,
|
||||||
|
priority: "NORMAL",
|
||||||
|
dueDate: `${dueDate}T00:00:00.000Z`,
|
||||||
|
status: "OPEN",
|
||||||
|
assignedUserId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
relatedType: "GENERAL",
|
||||||
|
relatedId: null,
|
||||||
|
sourceInspectionId: null,
|
||||||
|
sourceTemplateId: null,
|
||||||
|
sourceAutomationTrigger: null,
|
||||||
|
createdAt: "2026-10-01T00:00:00.000Z",
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function season(overrides: Partial<SeasonOfApiary>): SeasonOfApiary {
|
||||||
|
return {
|
||||||
|
apiaryId: "a-1",
|
||||||
|
apiaryName: "Obstwiese",
|
||||||
|
segment: "HERBST",
|
||||||
|
phaseSource: "CALENDAR",
|
||||||
|
segmentSince: "2026-09-15",
|
||||||
|
reportedOn: null,
|
||||||
|
note: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWheel() {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<YearWheel />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("YearWheel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
vi.setSystemTime(new Date("2026-10-14T10:00:00"));
|
||||||
|
// Wednesday 14.10.: one overdue, three in the next four weeks, one far ahead.
|
||||||
|
vi.mocked(fetchTasks).mockResolvedValue([
|
||||||
|
task("late", "2026-10-10"),
|
||||||
|
task("w1", "2026-10-16"),
|
||||||
|
task("w2", "2026-10-21"),
|
||||||
|
task("w3", "2026-11-04"),
|
||||||
|
task("far", "2027-03-01"),
|
||||||
|
]);
|
||||||
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({ seasons: [season({})] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the centre shows the segment, the source and the running window", async () => {
|
||||||
|
const { container } = renderWheel();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { level: 2, name: "Bienenjahr" })).toBeInTheDocument();
|
||||||
|
// The title names the image, the description describes it (not both in the name).
|
||||||
|
const wheel = screen.getByRole("img", { name: "Bienenjahr 2026" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(wheel).toHaveAccessibleDescription(
|
||||||
|
"Heute, 14.10.: Herbst seit 15.09. nach Kalender. Einwinterung läuft bis etwa 15.11. Danach Varroa-Winterbehandlung 15.11. bis 20.12. Offene Aufgaben mit Termin: 3 in den nächsten 4 Wochen, 1 überfällig.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The centre fits the free circle: title plus two short lines; the long forms sit below.
|
||||||
|
const centre = container.querySelector('[data-part="centre"]') as HTMLElement;
|
||||||
|
expect(centre.children).toHaveLength(3);
|
||||||
|
expect(within(centre).getByText("Herbst")).toBeInTheDocument();
|
||||||
|
expect(within(centre).getByText("seit 15.09.")).toBeInTheDocument();
|
||||||
|
expect(within(centre).getByText("Einwinterung")).toBeInTheDocument();
|
||||||
|
expect(within(centre).getByText("bis 15.11.")).toBeInTheDocument();
|
||||||
|
// The box follows the wheel size (container units), line 3 shortens by container query.
|
||||||
|
expect(centre.className).toMatch(/cqw/);
|
||||||
|
expect(within(centre).getByText("Einwinterung").className).toMatch(/@min-/);
|
||||||
|
expect(screen.getByText("Einwinterung bis ca. 15.11.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Jetzt: Herbst seit 15.09. (Kalender) · Danach: Winterruhe ab ca. 15.11.")).toBeInTheDocument();
|
||||||
|
// All text is HTML at fixed sizes; SVG text would shrink with the viewBox.
|
||||||
|
expect(wheel.querySelector("text")).toBeNull();
|
||||||
|
expect(wheel.querySelectorAll("a, button, [tabindex]")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the report date below the wheel and keeps the weak estimate out of the centre", async () => {
|
||||||
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(
|
||||||
|
todayFixture({
|
||||||
|
seasons: [season({ phaseSource: "REPORT", reportedOn: "2026-09-20", segmentSince: "2026-09-20", note: "WEATHER_GAPS" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { container } = renderWheel();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/^Jetzt: Herbst seit 20\.09\. \(gemeldet am 20\.09\.\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Einwinterung bis ca. 15.11. · Wetterdaten lückenhaft")).toBeInTheDocument();
|
||||||
|
const centre = container.querySelector('[data-part="centre"]') as HTMLElement;
|
||||||
|
expect(centre).not.toHaveTextContent(/Wetterdaten/);
|
||||||
|
// „20.09.“ ends the sentence: no double dot.
|
||||||
|
const wheel = screen.getByRole("img", { name: "Bienenjahr 2026" });
|
||||||
|
await waitFor(() => expect(wheel).toHaveAccessibleDescription(/seit 20\.09\. gemeldet am 20\.09\. Einwinterung/));
|
||||||
|
expect(container.querySelector("desc")?.textContent).not.toMatch(/\.\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("„Als Tabelle“ opens the segment and window tables with the weekly task row", async () => {
|
||||||
|
renderWheel();
|
||||||
|
const toggle = await screen.findByRole("button", { name: "Als Tabelle" });
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||||
|
// aria-controls always points at an element; closed it is hidden.
|
||||||
|
const panel = document.getElementById(toggle.getAttribute("aria-controls") ?? "");
|
||||||
|
expect(panel).not.toBeNull();
|
||||||
|
expect(panel).not.toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
const segments = await screen.findByRole("table", { name: "Abschnitte des Bienenjahrs" });
|
||||||
|
expect(within(segments).getAllByRole("columnheader").map((cell) => cell.textContent)).toEqual([
|
||||||
|
"Abschnitt",
|
||||||
|
"von",
|
||||||
|
"bis",
|
||||||
|
"Quelle",
|
||||||
|
]);
|
||||||
|
const herbst = within(segments).getByRole("row", { name: /Herbst/ });
|
||||||
|
expect(within(herbst).getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["15.09.", "ca. 15.11.", "Kalender"]);
|
||||||
|
const windows = screen.getByRole("table", { name: "Arbeitsfenster" });
|
||||||
|
expect(within(windows).getByRole("row", { name: /Einwinterung/ })).toHaveTextContent("15.09.15.11.");
|
||||||
|
const weeks = screen.getByRole("table", { name: "Aufgaben je Woche" });
|
||||||
|
const row = within(weeks).getByRole("row", { name: /Aufgaben je Woche/ });
|
||||||
|
expect(within(row).getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["1", "1", "0", "1", "1"]);
|
||||||
|
expect(within(weeks).getAllByRole("columnheader").map((cell) => cell.textContent)).toEqual([
|
||||||
|
"Woche",
|
||||||
|
"ab 12.10.",
|
||||||
|
"ab 19.10.",
|
||||||
|
"ab 26.10.",
|
||||||
|
"ab 02.11.",
|
||||||
|
"überfällig",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("„Zum Kalender“ links to the calendar", async () => {
|
||||||
|
renderWheel();
|
||||||
|
|
||||||
|
const link = await screen.findByRole("link", { name: "Zum Kalender" });
|
||||||
|
expect(link).toHaveAttribute("href", "/calendar");
|
||||||
|
expect(link).toHaveClass("min-h-11");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names deviating apiaries in the third centre line", async () => {
|
||||||
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(
|
||||||
|
todayFixture({
|
||||||
|
seasons: [
|
||||||
|
season({}),
|
||||||
|
season({ apiaryId: "a-2", apiaryName: "Dorf", segment: "HERBST" }),
|
||||||
|
season({ apiaryId: "a-3", apiaryName: "Südhang", segment: "SPAETSOMMER", segmentSince: "2026-08-01" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const first = renderWheel();
|
||||||
|
// Deviations sit in the line below the wheel, not in the narrow centre.
|
||||||
|
expect(await screen.findByText("Einwinterung bis ca. 15.11. · Südhang: noch Spätsommer")).toBeInTheDocument();
|
||||||
|
expect(first.container.querySelector('[data-part="centre"]')).not.toHaveTextContent(/Südhang/);
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(
|
||||||
|
todayFixture({
|
||||||
|
seasons: [
|
||||||
|
season({}),
|
||||||
|
season({ apiaryId: "a-2", apiaryName: "Dorf", segment: "HERBST" }),
|
||||||
|
season({ apiaryId: "a-3", apiaryName: "Südhang", segment: "SPAETSOMMER", segmentSince: "2026-08-01" }),
|
||||||
|
season({ apiaryId: "a-4", apiaryName: "Moor", segment: "WINTERRUHE", segmentSince: "2026-10-10" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderWheel();
|
||||||
|
expect(await screen.findByText(/· 2 Stände weichen ab – siehe Tabelle$/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Als Tabelle" }));
|
||||||
|
const segments = screen.getByRole("table", { name: "Abschnitte des Bienenjahrs" });
|
||||||
|
// One column per apiary when they deviate: where each one is now.
|
||||||
|
expect(within(segments).getAllByRole("columnheader").map((cell) => cell.textContent)).toEqual([
|
||||||
|
"Abschnitt",
|
||||||
|
"von",
|
||||||
|
"bis",
|
||||||
|
"Quelle",
|
||||||
|
"Obstwiese",
|
||||||
|
"Dorf",
|
||||||
|
"Südhang",
|
||||||
|
"Moor",
|
||||||
|
]);
|
||||||
|
const winter = within(segments).getByRole("row", { name: /Winterruhe/ });
|
||||||
|
expect(winter).toHaveTextContent("jetzt, seit 10.10.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works on the calendar alone without the autopilot", async () => {
|
||||||
|
vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
renderWheel();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/^Jetzt: Herbst seit 15\.09\. \(Kalender\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("img", { name: "Bienenjahr 2026" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the pointer is a fixed element without animation", async () => {
|
||||||
|
const { container } = renderWheel();
|
||||||
|
// The tasks are in once the description counts the overdue one.
|
||||||
|
await screen.findByText(/1 überfällig\.$/);
|
||||||
|
|
||||||
|
const pointer = container.querySelector('[data-part="pointer"]');
|
||||||
|
expect(pointer).not.toBeNull();
|
||||||
|
expect(pointer?.getAttribute("style")).toBeNull();
|
||||||
|
const svg = container.querySelector("svg[role='img']") as SVGElement;
|
||||||
|
expect(svg.querySelectorAll("animate, animateTransform, animateMotion, set")).toHaveLength(0);
|
||||||
|
// The wheel itself; the „Als Tabelle“ button keeps the app's button feedback.
|
||||||
|
expect(svg.parentElement?.innerHTML).not.toMatch(/\b(animate-|transition)/);
|
||||||
|
// The current segment is honey framed in ink; the overdue dot sits before the pointer.
|
||||||
|
expect(container.querySelectorAll('[data-part="segment"][data-current="true"]').length).toBeGreaterThan(0);
|
||||||
|
expect(container.querySelector('[data-part="overdue"]')).not.toBeNull();
|
||||||
|
expect(container.querySelectorAll('[data-part="task-week"]')).toHaveLength(4);
|
||||||
|
// The honeycomb cap ends inside the ring of month initials (r = 150, 13–14 px text).
|
||||||
|
const cap = pointer?.querySelector("polygon")?.getAttribute("points") ?? "";
|
||||||
|
const reach = Math.max(
|
||||||
|
...cap.split(" ").map((xy) => {
|
||||||
|
const [x, y] = xy.split(",").map(Number);
|
||||||
|
return Math.hypot(x - 160, y - 160);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(reach).toBeGreaterThan(0);
|
||||||
|
expect(reach).toBeLessThanOrEqual(141);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("this week's dot keeps clear of next week's dot", async () => {
|
||||||
|
// Sunday: next week's mid-week is only four days away.
|
||||||
|
vi.setSystemTime(new Date("2026-10-18T10:00:00"));
|
||||||
|
vi.mocked(fetchTasks).mockResolvedValue([
|
||||||
|
task("now", "2026-10-18"),
|
||||||
|
...["19", "20", "21", "22"].map((day) => task(`n${day}`, `2026-10-${day}`)),
|
||||||
|
]);
|
||||||
|
const { container } = renderWheel();
|
||||||
|
await screen.findByText(/5 in den nächsten 4 Wochen/);
|
||||||
|
|
||||||
|
const dots = [...container.querySelectorAll('[data-part="task-week"]')].map((dot) => ({
|
||||||
|
x: Number(dot.getAttribute("cx")),
|
||||||
|
y: Number(dot.getAttribute("cy")),
|
||||||
|
r: Number(dot.getAttribute("r")),
|
||||||
|
}));
|
||||||
|
expect(dots).toHaveLength(2);
|
||||||
|
expect(Math.hypot(dots[0].x - dots[1].x, dots[0].y - dots[1].y)).toBeGreaterThanOrEqual(dots[0].r + dots[1].r + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the legend shows each window as the ring does, also in forced colours", async () => {
|
||||||
|
const { container } = renderWheel();
|
||||||
|
await screen.findByRole("img", { name: "Bienenjahr 2026" });
|
||||||
|
|
||||||
|
for (const [key, legend] of [["TRACHT", "TRACHT"], ["VARROA_WINTER", "VARROA"], ["EINWINTERUNG", "EINWINTERUNG"]]) {
|
||||||
|
const ring = container.querySelector(`[data-window="${key}"]`);
|
||||||
|
const swatch = container.querySelector(`[data-legend="${legend}"]`);
|
||||||
|
expect(ring).not.toBeNull();
|
||||||
|
expect(swatch?.getAttribute("class")).toBe(ring?.getAttribute("class"));
|
||||||
|
}
|
||||||
|
const forced = (key: string) =>
|
||||||
|
(container.querySelector(`[data-window="${key}"]`)?.getAttribute("class") ?? "")
|
||||||
|
.split(" ")
|
||||||
|
.filter((name) => name.startsWith("forced-colors:"))
|
||||||
|
.join(" ");
|
||||||
|
// In forced colours Tracht stays full and the wintering window turns hollow.
|
||||||
|
expect(forced("TRACHT")).not.toBe(forced("EINWINTERUNG"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||||
|
import { daysBetween } from "@/lib/autopilot/format";
|
||||||
|
import { todayLocalIsoDate } from "@/lib/dates";
|
||||||
|
import { angleOf, arcsOf, buildYearWheel, type YearWheelModel, type YearWheelWindowKey } from "@/lib/season/year-wheel";
|
||||||
|
import { fetchTasks } from "@/lib/tasks/api";
|
||||||
|
import { SEGMENT_LABELS } from "@/components/autopilot/season-chip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { YearWheelTable } from "./year-wheel-table";
|
||||||
|
import { centreLines, detailLine, nowAndNext, wheelDescription } from "./year-wheel-text";
|
||||||
|
|
||||||
|
// Spec 9.2, in px of the 320 × 320 viewBox.
|
||||||
|
const SIZE = 320;
|
||||||
|
const CENTER = SIZE / 2;
|
||||||
|
const OUTER_R = 124;
|
||||||
|
const OUTER_W = 26;
|
||||||
|
const INNER_R = 96;
|
||||||
|
const INNER_W = 10;
|
||||||
|
const DOT_R = 78;
|
||||||
|
const MONTH_R = 150;
|
||||||
|
const POINTER_FROM = 66;
|
||||||
|
/** The cap ends at r = 140, clear of the month initials at r = 150 (13–14 px). */
|
||||||
|
const POINTER_TO = 134;
|
||||||
|
const CAP_R = 6;
|
||||||
|
const SEGMENT_GAP = 2;
|
||||||
|
/** The overdue dot sits this far before the pointer (spec 9.2). */
|
||||||
|
const NEAR_POINTER_DEG = 4;
|
||||||
|
/** Half the pointer's 3 px stroke: the first dot after it keeps clear of that. */
|
||||||
|
const POINTER_HALF = 1.5;
|
||||||
|
const PX_PER_DEG_AT_DOTS = (DOT_R * Math.PI) / 180;
|
||||||
|
const MONTH_INITIALS = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
|
||||||
|
const INK = "forced-colors:stroke-[CanvasText]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared by ring and legend. Tracht is full, wintering full but hollow in forced colours, the
|
||||||
|
* two Varroa windows hatched: told apart without colour.
|
||||||
|
*/
|
||||||
|
const WINDOW_CLASS: Record<YearWheelWindowKey, string> = {
|
||||||
|
TRACHT: "fill-chart-3 forced-colors:fill-[CanvasText]",
|
||||||
|
VARROA_SUMMER: cn("stroke-chart-4", INK),
|
||||||
|
EINWINTERUNG: "fill-chart-5 forced-colors:fill-[Canvas] forced-colors:stroke-[CanvasText]",
|
||||||
|
VARROA_WINTER: cn("stroke-chart-4", INK),
|
||||||
|
};
|
||||||
|
const isHatched = (key: YearWheelWindowKey): boolean => key === "VARROA_SUMMER" || key === "VARROA_WINTER";
|
||||||
|
const DOT_CLASS = "fill-foreground forced-colors:fill-[CanvasText]";
|
||||||
|
const OVERDUE_CLASS = cn("fill-condition-critical stroke-foreground forced-colors:fill-[Canvas]", INK);
|
||||||
|
|
||||||
|
const round = (value: number): number => Math.round(value * 10) / 10;
|
||||||
|
|
||||||
|
function polar(angle: number, radius: number): [number, number] {
|
||||||
|
const rad = (angle * Math.PI) / 180;
|
||||||
|
return [round(CENTER + radius * Math.cos(rad)), round(CENTER + radius * Math.sin(rad))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function arcTo(angle: number, radius: number, sweep: 0 | 1, large: number): string {
|
||||||
|
const [x, y] = polar(angle, radius);
|
||||||
|
return `A${radius} ${radius} 0 ${large} ${sweep} ${x} ${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ring piece from `start` to `end` (degrees, clockwise) around `radius`, `width` wide. */
|
||||||
|
function bandPath(start: number, end: number, radius: number, width: number): string {
|
||||||
|
const outer = radius + width / 2;
|
||||||
|
const inner = radius - width / 2;
|
||||||
|
const large = end - start > 180 ? 1 : 0;
|
||||||
|
const [x1, y1] = polar(start, outer);
|
||||||
|
const [x2, y2] = polar(end, inner);
|
||||||
|
return `M${x1} ${y1}${arcTo(end, outer, 1, large)}L${x2} ${y2}${arcTo(start, inner, 0, large)}Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the outer and inner edge of a ring piece, so the radial edges can be dashed apart. */
|
||||||
|
function bandEdges(start: number, end: number, radius: number, width: number): string {
|
||||||
|
const large = end - start > 180 ? 1 : 0;
|
||||||
|
return [radius + width / 2, radius - width / 2]
|
||||||
|
.map((r) => {
|
||||||
|
const [x, y] = polar(start, r);
|
||||||
|
return `M${x} ${y}${arcTo(end, r, 1, large)}`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The arcs of a segment, 2 px apart from its neighbours. */
|
||||||
|
function segmentArcs(from: string, to: string) {
|
||||||
|
const gap = ((SEGMENT_GAP / 2 / OUTER_R) * 180) / Math.PI;
|
||||||
|
const arcs = arcsOf(from, to);
|
||||||
|
return arcs
|
||||||
|
.map((arc, index) => ({
|
||||||
|
startAngle: index === 0 ? arc.startAngle + gap : arc.startAngle,
|
||||||
|
endAngle: index === arcs.length - 1 ? arc.endAngle - gap : arc.endAngle,
|
||||||
|
}))
|
||||||
|
.filter((arc) => arc.endAngle > arc.startAngle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadialEdge({ angle, dashed }: { angle: number; dashed: boolean }) {
|
||||||
|
const [x1, y1] = polar(angle, OUTER_R - OUTER_W / 2);
|
||||||
|
const [x2, y2] = polar(angle, OUTER_R + OUTER_W / 2);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
x1={x1}
|
||||||
|
y1={y1}
|
||||||
|
x2={x2}
|
||||||
|
y2={y2}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray={dashed ? "3 2" : undefined}
|
||||||
|
className={cn("stroke-input", INK)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segments({ model }: { model: YearWheelModel }) {
|
||||||
|
return model.segments.map((segment, index) => {
|
||||||
|
const arcs = segmentArcs(segment.from, segment.to);
|
||||||
|
if (arcs.length === 0) return null;
|
||||||
|
const first = arcs[0];
|
||||||
|
const last = arcs[arcs.length - 1];
|
||||||
|
if (segment.current) {
|
||||||
|
return arcs.map((arc) => (
|
||||||
|
<path
|
||||||
|
key={`${segment.key}-${arc.startAngle}`}
|
||||||
|
data-part="segment"
|
||||||
|
data-current="true"
|
||||||
|
d={bandPath(arc.startAngle, arc.endAngle, OUTER_R, OUTER_W)}
|
||||||
|
strokeWidth={2}
|
||||||
|
className={cn("fill-primary stroke-foreground forced-colors:fill-[Highlight]", INK)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
const nextIsEstimated = model.segments[(index + 1) % model.segments.length].estimated;
|
||||||
|
return (
|
||||||
|
<g key={segment.key} data-part="segment" data-current="false">
|
||||||
|
{arcs.map((arc) => (
|
||||||
|
<path key={arc.startAngle} d={bandPath(arc.startAngle, arc.endAngle, OUTER_R, OUTER_W)} className="fill-muted forced-colors:fill-[Canvas]" />
|
||||||
|
))}
|
||||||
|
<path
|
||||||
|
d={arcs.map((arc) => bandEdges(arc.startAngle, arc.endAngle, OUTER_R, OUTER_W)).join("")}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={cn("stroke-input", INK)}
|
||||||
|
/>
|
||||||
|
<RadialEdge angle={first.startAngle} dashed={segment.estimated} />
|
||||||
|
<RadialEdge angle={last.endAngle} dashed={nextIsEstimated} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Windows({ model, hatchId }: { model: YearWheelModel; hatchId: string }) {
|
||||||
|
return model.windows.flatMap((window) =>
|
||||||
|
arcsOf(window.from, window.to).map((arc) => (
|
||||||
|
<path
|
||||||
|
key={`${window.key}-${arc.startAngle}`}
|
||||||
|
data-part="window"
|
||||||
|
data-window={window.key}
|
||||||
|
d={bandPath(arc.startAngle, arc.endAngle, INNER_R, INNER_W)}
|
||||||
|
fill={isHatched(window.key) ? `url(#${hatchId})` : undefined}
|
||||||
|
strokeWidth={1}
|
||||||
|
className={WINDOW_CLASS[window.key]}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dot per week with open tasks: 3 / 4.5 / 6 px for 1 / 2–3 / 4 and more (spec 9.2), at
|
||||||
|
* mid-week. A dot never touches the pointer or the dot before it: it moves on clockwise.
|
||||||
|
*/
|
||||||
|
function dotPlacements(model: YearWheelModel, pointer: number): { weekStart: string; angle: number; r: number }[] {
|
||||||
|
const dots: { weekStart: string; angle: number; r: number }[] = [];
|
||||||
|
let previous = { angle: pointer, r: POINTER_HALF };
|
||||||
|
for (const week of model.taskWeeks) {
|
||||||
|
const midWeek = new Date(Date.parse(`${week.weekStart}T00:00:00Z`) + 3 * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
const r = week.count >= 4 ? 6 : week.count >= 2 ? 4.5 : 3;
|
||||||
|
// Angles counted on from the pointer, so a dot next spring is not "before" today.
|
||||||
|
const ahead = daysBetween(model.today, midWeek) <= 0 ? 0 : (angleOf(midWeek) - pointer + 360) % 360;
|
||||||
|
// 1.5 px air: 1 px gap plus the chord being a little shorter than the arc and rounding.
|
||||||
|
const clear = previous.angle + (previous.r + r + 1.5) / PX_PER_DEG_AT_DOTS;
|
||||||
|
// shortcut: a year of busy weeks would push dots past the overdue dot; capped short of it.
|
||||||
|
const angle = Math.min(Math.max(pointer + ahead, clear), pointer + 360 - 2 * NEAR_POINTER_DEG);
|
||||||
|
dots.push({ weekStart: week.weekStart, angle, r });
|
||||||
|
previous = { angle, r };
|
||||||
|
}
|
||||||
|
return dots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskDots({ model, pointer }: { model: YearWheelModel; pointer: number }) {
|
||||||
|
return dotPlacements(model, pointer).map((dot) => {
|
||||||
|
const [cx, cy] = polar(dot.angle, DOT_R);
|
||||||
|
return <circle key={dot.weekStart} data-part="task-week" cx={cx} cy={cy} r={dot.r} className={DOT_CLASS} />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pointer({ angle }: { angle: number }) {
|
||||||
|
const [x1, y1] = polar(angle, POINTER_FROM);
|
||||||
|
const [x2, y2] = polar(angle, POINTER_TO);
|
||||||
|
const cell = [0, 60, 120, 180, 240, 300]
|
||||||
|
.map((corner) => {
|
||||||
|
const rad = ((angle + corner + 30) * Math.PI) / 180;
|
||||||
|
return `${round(x2 + CAP_R * Math.cos(rad))},${round(y2 + CAP_R * Math.sin(rad))}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
// Fixed on today: no transform, no transition (spec 9.5, 17.5).
|
||||||
|
return (
|
||||||
|
<g data-part="pointer">
|
||||||
|
<line x1={x1} y1={y1} x2={x2} y2={y2} strokeWidth={3} strokeLinecap="round" className={cn("stroke-foreground", INK)} />
|
||||||
|
<polygon points={cell} strokeWidth={1.5} className={cn("fill-primary stroke-foreground forced-colors:fill-[Canvas]", INK)} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Legend({ hatchId }: { hatchId: string }) {
|
||||||
|
const swatch = (child: ReactNode) => (
|
||||||
|
<svg width={16} height={10} viewBox="0 0 16 10" aria-hidden="true" className="shrink-0">
|
||||||
|
{child}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const band = (legend: string, key: YearWheelWindowKey) => (
|
||||||
|
<rect
|
||||||
|
data-legend={legend}
|
||||||
|
x={0.5}
|
||||||
|
y={0.5}
|
||||||
|
width={15}
|
||||||
|
height={9}
|
||||||
|
fill={isHatched(key) ? `url(#${hatchId})` : undefined}
|
||||||
|
strokeWidth={1}
|
||||||
|
className={WINDOW_CLASS[key]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const items: [string, ReactNode][] = [
|
||||||
|
["Tracht", band("TRACHT", "TRACHT")],
|
||||||
|
["Varroa-Behandlung", band("VARROA", "VARROA_WINTER")],
|
||||||
|
["Einwinterung", band("EINWINTERUNG", "EINWINTERUNG")],
|
||||||
|
["Aufgabe", <circle key="dot" cx={8} cy={5} r={3} className={DOT_CLASS} />],
|
||||||
|
["überfällig", <circle key="overdue" cx={8} cy={5} r={3.5} strokeWidth={1.5} className={OVERDUE_CLASS} />],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs">
|
||||||
|
{items.map(([label, shape]) => (
|
||||||
|
<li key={label} className="inline-flex items-center gap-1.5">
|
||||||
|
{swatch(shape)}
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wheel({ model }: { model: YearWheelModel }) {
|
||||||
|
const ids = useId().replace(/[^\w-]/g, "");
|
||||||
|
const hatchId = `wheel-hatch-${ids}`;
|
||||||
|
const pointer = angleOf(model.today);
|
||||||
|
const centre = centreLines(model);
|
||||||
|
const detail = detailLine(model);
|
||||||
|
const [overdueX, overdueY] = polar(pointer - NEAR_POINTER_DEG, DOT_R);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* @container: the centre text sizes and shortens with the wheel, not the viewport. */}
|
||||||
|
<div className="@container relative aspect-square w-full max-w-80">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
role="img"
|
||||||
|
aria-labelledby={`wheel-title-${ids}`}
|
||||||
|
aria-describedby={`wheel-desc-${ids}`}
|
||||||
|
className="absolute inset-0 size-full"
|
||||||
|
>
|
||||||
|
<title id={`wheel-title-${ids}`}>{`Bienenjahr ${model.year}`}</title>
|
||||||
|
<desc id={`wheel-desc-${ids}`}>{wheelDescription(model)}</desc>
|
||||||
|
<defs>
|
||||||
|
<pattern id={hatchId} width={4} height={4} patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||||
|
<line x1={2} y1={0} x2={2} y2={4} strokeWidth={2} className={cn("stroke-chart-4", INK)} />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<Segments model={model} />
|
||||||
|
<Windows model={model} hatchId={hatchId} />
|
||||||
|
<TaskDots model={model} pointer={pointer} />
|
||||||
|
{model.overdueCount > 0 ? (
|
||||||
|
<circle
|
||||||
|
data-part="overdue"
|
||||||
|
cx={overdueX}
|
||||||
|
cy={overdueY}
|
||||||
|
r={4.5}
|
||||||
|
strokeWidth={2}
|
||||||
|
className={OVERDUE_CLASS}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Pointer angle={pointer} />
|
||||||
|
</svg>
|
||||||
|
{/* Text as HTML at fixed sizes: SVG text would shrink with the wheel (spec 9.2). */}
|
||||||
|
{MONTH_INITIALS.map((initial, month) => {
|
||||||
|
const [x, y] = polar(angleOf(`${model.year}-${String(month + 1).padStart(2, "0")}-15`), MONTH_R);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={month}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -translate-x-1/2 -translate-y-1/2 text-xs leading-none text-muted-foreground"
|
||||||
|
style={{ left: `${(x / SIZE) * 100}%`, top: `${(y / SIZE) * 100}%` }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/*
|
||||||
|
The free circle is r < 62 of 320 (38.75cqw across). Three single lines at most: the
|
||||||
|
title scales with the wheel (13–22 px), the two lines are text-sm (sun 16 px) and short.
|
||||||
|
At a 256 px wheel (320 px viewport) in sun: „Schwarmzeit“ ≈ 79 px, „seit 15.09.“ ≈ 80 px,
|
||||||
|
„bis 15.11.“ ≈ 75 px; from 304 px the window name („Einwinterung“ ≈ 101 px) instead.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-part="centre"
|
||||||
|
className="absolute top-1/2 left-1/2 flex w-[38cqw] -translate-x-1/2 -translate-y-1/2 flex-col items-center text-center leading-tight whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<p className="font-heading text-[clamp(13px,5.2cqw,22px)] font-semibold">{SEGMENT_LABELS[model.segments[0].key]}</p>
|
||||||
|
<p className="text-sm">{centre.since}</p>
|
||||||
|
{centre.window ? (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="hidden @min-[19rem]:inline">{centre.window.name}</span>
|
||||||
|
<span className="@min-[19rem]:hidden">{centre.window.end}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Legend hatchId={hatchId} />
|
||||||
|
<p className="text-center text-sm">{nowAndNext(model)}</p>
|
||||||
|
{detail ? <p className="text-center text-sm">{detail}</p> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bee year at a glance (spec 9.2–9.5): segments, work windows, open tasks and a pointer on
|
||||||
|
* today. Nothing in the wheel is tappable or animated; the table says the same in words.
|
||||||
|
*/
|
||||||
|
export function YearWheel() {
|
||||||
|
const [isTableOpen, setTableOpen] = useState(false);
|
||||||
|
const headingId = useId();
|
||||||
|
const tableId = useId();
|
||||||
|
// Same queries as the dashboard, so the wheel adds no request.
|
||||||
|
const tasksQuery = useQuery({ queryKey: ["tasks", "dashboard"], queryFn: () => fetchTasks({}) });
|
||||||
|
const autopilotQuery = useQuery({ queryKey: autopilotTodayKey, queryFn: fetchAutopilotToday, retry: false });
|
||||||
|
const today = todayLocalIsoDate();
|
||||||
|
const model = useMemo(
|
||||||
|
() => buildYearWheel({ today: autopilotQuery.data, tasks: tasksQuery.data ?? [] }, today),
|
||||||
|
[autopilotQuery.data, tasksQuery.data, today],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 id={headingId} className="font-heading text-lg font-semibold tracking-tight">
|
||||||
|
Bienenjahr
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href="/calendar"
|
||||||
|
className="inline-flex min-h-11 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Zum Kalender
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border/60 bg-card p-4">
|
||||||
|
<Wheel model={model} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="touch"
|
||||||
|
variant="outline"
|
||||||
|
className="self-start"
|
||||||
|
aria-expanded={isTableOpen}
|
||||||
|
aria-controls={tableId}
|
||||||
|
onClick={() => setTableOpen(!isTableOpen)}
|
||||||
|
>
|
||||||
|
Als Tabelle
|
||||||
|
</Button>
|
||||||
|
{/* Always there, so aria-controls resolves; hidden while closed. */}
|
||||||
|
<div id={tableId} hidden={!isTableOpen} className="w-full">
|
||||||
|
<YearWheelTable model={model} seasons={autopilotQuery.data?.seasons ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
|
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
|
||||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||||
import { SeasonChip } from "@/components/autopilot/season-chip";
|
|
||||||
import { StepActions } from "@/components/autopilot/next-steps-card";
|
import { StepActions } from "@/components/autopilot/next-steps-card";
|
||||||
import { useStepActions } from "@/components/autopilot/use-step-actions";
|
import { useStepActions } from "@/components/autopilot/use-step-actions";
|
||||||
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
|
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
|
||||||
@@ -363,29 +362,17 @@ function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open suggestions and the season of each apiary - small, but readable as words. */
|
/** Open suggestions - small, but readable as words. The season per apiary is in the bee-year wheel. */
|
||||||
function AutopilotChips({ autopilot }: { autopilot: AutopilotToday }) {
|
function AutopilotChips({ autopilot }: { autopilot: AutopilotToday }) {
|
||||||
const showSuggestions = autopilot.tasksVisible && autopilot.suggestionsOpen > 0;
|
if (!autopilot.tasksVisible || autopilot.suggestionsOpen === 0) return null;
|
||||||
if (!showSuggestions && autopilot.seasons.length === 0) return null;
|
|
||||||
return (
|
return (
|
||||||
<ul aria-label="Vorschläge und Saison" className="flex flex-wrap items-center gap-2">
|
|
||||||
{showSuggestions ? (
|
|
||||||
<li>
|
|
||||||
<Link
|
<Link
|
||||||
href="/suggestions"
|
href="/suggestions"
|
||||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-primary/12 px-4 text-sm font-medium"
|
className="inline-flex min-h-11 items-center gap-1.5 self-start rounded-full bg-primary/12 px-4 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<Lightbulb className="size-4" aria-hidden />
|
<Lightbulb className="size-4" aria-hidden />
|
||||||
Vorschläge {autopilot.suggestionsOpen}
|
Vorschläge {autopilot.suggestionsOpen}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
{autopilot.seasons.map((season) => (
|
|
||||||
<li key={season.apiaryId}>
|
|
||||||
<SeasonChip segment={season.segment} apiaryName={season.apiaryName} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { apiFetch } from "@/lib/auth/api-client";
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
import { DashboardAlert, DashboardSummary } from "./types";
|
import { DashboardAlert } from "./types";
|
||||||
|
|
||||||
export function fetchDashboardSummary(): Promise<DashboardSummary> {
|
|
||||||
return apiFetch<DashboardSummary>("/dashboard/summary");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchDashboardAlerts(): Promise<DashboardAlert[]> {
|
export function fetchDashboardAlerts(): Promise<DashboardAlert[]> {
|
||||||
return apiFetch<DashboardAlert[]>("/dashboard/alerts");
|
return apiFetch<DashboardAlert[]>("/dashboard/alerts");
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
export interface DashboardSummary {
|
|
||||||
apiariesActive: number;
|
|
||||||
coloniesActive: number;
|
|
||||||
queensActive: number;
|
|
||||||
openTasks: number;
|
|
||||||
honeyBatchesTotal: number;
|
|
||||||
stockItemsLowCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DashboardAlertType =
|
export type DashboardAlertType =
|
||||||
| "OVERDUE_TASK"
|
| "OVERDUE_TASK"
|
||||||
| "COLONY_ATTENTION"
|
| "COLONY_ATTENTION"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ describe("buildYearWheel", () => {
|
|||||||
source: "REPORT",
|
source: "REPORT",
|
||||||
current: true,
|
current: true,
|
||||||
estimated: false,
|
estimated: false,
|
||||||
|
lateStart: false,
|
||||||
});
|
});
|
||||||
// The ring closes where it began: spring ends at the reported day.
|
// The ring closes where it began: spring ends at the reported day.
|
||||||
expect(model.segments[5]).toMatchObject({ key: "FRUEHJAHR", from: "2027-03-20", to: "2027-04-05" });
|
expect(model.segments[5]).toMatchObject({ key: "FRUEHJAHR", from: "2027-03-20", to: "2027-04-05" });
|
||||||
@@ -103,7 +104,13 @@ describe("buildYearWheel", () => {
|
|||||||
it("a late bloom makes the next segment „ab morgen, noch offen“", () => {
|
it("a late bloom makes the next segment „ab morgen, noch offen“", () => {
|
||||||
const model = wheel([season({ segment: "FRUEHJAHR", segmentSince: "2026-03-20" })], "2026-04-20");
|
const model = wheel([season({ segment: "FRUEHJAHR", segmentSince: "2026-03-20" })], "2026-04-20");
|
||||||
expect(model.segments[0]).toMatchObject({ key: "FRUEHJAHR", from: "2026-03-20", to: "2026-04-21" });
|
expect(model.segments[0]).toMatchObject({ key: "FRUEHJAHR", from: "2026-03-20", to: "2026-04-21" });
|
||||||
expect(model.segments[1]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-21", to: "2026-06-20", estimated: true });
|
expect(model.segments[1]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-21", to: "2026-06-20", estimated: true, lateStart: true });
|
||||||
|
expect(model.segments.filter((s) => s.lateStart)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a calendar start that happens to be tomorrow is no late bloom", () => {
|
||||||
|
const model = wheel([season({ segment: "FRUEHJAHR", segmentSince: "2026-03-20" })], "2026-04-14");
|
||||||
|
expect(model.segments[1]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-15", lateStart: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("segments are gapless around the ring", () => {
|
it("segments are gapless around the ring", () => {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface YearWheelSegment {
|
|||||||
current: boolean;
|
current: boolean;
|
||||||
/** A calendar boundary, drawn dashed. */
|
/** A calendar boundary, drawn dashed. */
|
||||||
estimated: boolean;
|
estimated: boolean;
|
||||||
|
/** Its calendar day has passed but it has not come yet (late bloom): „ab morgen, noch offen“. */
|
||||||
|
lateStart: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type YearWheelWindowKey = "TRACHT" | "VARROA_SUMMER" | "EINWINTERUNG" | "VARROA_WINTER";
|
export type YearWheelWindowKey = "TRACHT" | "VARROA_SUMMER" | "EINWINTERUNG" | "VARROA_WINTER";
|
||||||
@@ -165,10 +167,13 @@ function ringOf(
|
|||||||
// The ring closes a year after the current segment began; a stale segment caps the rest there.
|
// The ring closes a year after the current segment began; a stale segment caps the rest there.
|
||||||
const ringEnd = nextYear(current.since);
|
const ringEnd = nextYear(current.since);
|
||||||
const froms = [current.since];
|
const froms = [current.since];
|
||||||
|
const lateStarts = [false];
|
||||||
for (let step = 1; step < CYCLE.length; step += 1) {
|
for (let step = 1; step < CYCLE.length; step += 1) {
|
||||||
const index = first + step;
|
const index = first + step;
|
||||||
const calendarDay = `${beeYear + Math.floor(index / CYCLE.length)}-${starts[CYCLE[index % CYCLE.length]]}`;
|
const calendarDay = `${beeYear + Math.floor(index / CYCLE.length)}-${starts[CYCLE[index % CYCLE.length]]}`;
|
||||||
froms.push(earliest(latest(calendarDay, tomorrow, froms[step - 1]), ringEnd));
|
const from = earliest(latest(calendarDay, tomorrow, froms[step - 1]), ringEnd);
|
||||||
|
froms.push(from);
|
||||||
|
lateStarts.push(calendarDay < tomorrow && from === tomorrow);
|
||||||
}
|
}
|
||||||
const tos = [...froms.slice(1), ringEnd];
|
const tos = [...froms.slice(1), ringEnd];
|
||||||
return froms.map((from, step) => ({
|
return froms.map((from, step) => ({
|
||||||
@@ -178,6 +183,7 @@ function ringOf(
|
|||||||
source: step === 0 ? current.source : "CALENDAR",
|
source: step === 0 ? current.source : "CALENDAR",
|
||||||
current: step === 0,
|
current: step === 0,
|
||||||
estimated: step !== 0,
|
estimated: step !== 0,
|
||||||
|
lateStart: lateStarts[step],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user