feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers 409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar size, best-before (shelfLifeMonths, month-end clamping) and water content; impossible dates give 400; water-content checks in src/common/quality. - Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue, season header on the apiary page, confirmed revoke for all apiaries. - Frontend honey chain: "Vorschlag aus …" hints with explicit take-over, prefill status note, movement ledger with withdraw/correct dialogs. - Telegram: link tokens (hashed, single use), private-chat receiver with lease and offset CAS, bounded poison-update skip, Retry-After, pending deliveries skipped on disconnect; system API for deliveries and retry; worker-only secret wiring and manifest policy tests. - ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
16cc557462
commit
fab7f7e12f
91 files changed
+8463
-104
No files matched your search
@@ -19,4 +19,22 @@ describe("PreviousValueHint", () => {
|
||||
|
||||
expect(onTakeOver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps „Letztes Mal“ as default prefix and lets a caller name the source", () => {
|
||||
const { rerender } = render(
|
||||
<PreviousValueHint fieldLabel="Menge" valueLabel="42,5 kg" onTakeOver={() => undefined} />,
|
||||
);
|
||||
expect(screen.getByText(/Letztes Mal: 42,5 kg/)).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<PreviousValueHint
|
||||
fieldLabel="Menge"
|
||||
valueLabel="42,5 kg"
|
||||
prefix="Aus 2 Ernten"
|
||||
onTakeOver={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Aus 2 Ernten: 42,5 kg/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ export interface PreviousValueHintProps {
|
||||
/** The earlier answer in words, e.g. "Ja" or "4 von 5". */
|
||||
valueLabel: string;
|
||||
onTakeOver: () => void;
|
||||
/** Where the value comes from; "Letztes Mal" unless the caller names a source, e.g. "Aus 2 Ernten". */
|
||||
prefix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -20,12 +22,13 @@ export function PreviousValueHint({
|
||||
fieldLabel,
|
||||
valueLabel,
|
||||
onTakeOver,
|
||||
prefix = "Letztes Mal",
|
||||
className,
|
||||
}: PreviousValueHintProps) {
|
||||
return (
|
||||
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
|
||||
<span>
|
||||
Letztes Mal: {valueLabel} <span aria-hidden="true">·</span>
|
||||
{prefix}: {valueLabel} <span aria-hidden="true">·</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } 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 { createPhenologyReport, fetchPhenologyState } from "@/lib/phenology/api";
|
||||
import type { PhenologyState } from "@/lib/phenology/types";
|
||||
import { ApiarySeason } from "./apiary-season";
|
||||
|
||||
vi.mock("@/lib/phenology/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/phenology/api")>()),
|
||||
fetchPhenologyState: vi.fn(),
|
||||
fetchPhenologyReports: vi.fn().mockResolvedValue([]),
|
||||
createPhenologyReport: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
|
||||
fetchAutopilotToday: vi.fn(),
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ permissions: new Set<string>() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useCurrentUserId: () => "user-1",
|
||||
useAuth: () => ({
|
||||
hasPermission: (permission: string) => auth.permissions.has(permission),
|
||||
}),
|
||||
}));
|
||||
|
||||
const apiary = { id: "a-1", name: "Obstwiese" };
|
||||
|
||||
function state(overrides: Partial<PhenologyState>): PhenologyState {
|
||||
return {
|
||||
apiaryId: "a-1",
|
||||
apiaryName: "Obstwiese",
|
||||
phase: "KIRSCHE",
|
||||
segment: "SCHWARMZEIT",
|
||||
phaseSource: "REPORT",
|
||||
reportedOn: "2026-04-18",
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
note: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSeason() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ApiarySeason apiary={apiary} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
auth.permissions = new Set(["phenology:create"]);
|
||||
vi.mocked(fetchPhenologyState)
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
state({ apiaryId: "a-2", apiaryName: "Wald", segment: "SOMMER" }),
|
||||
state({}),
|
||||
]);
|
||||
vi.mocked(fetchAutopilotToday)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
nextStep: null,
|
||||
inspectionDue: [
|
||||
{
|
||||
apiaryId: "a-1",
|
||||
apiaryName: "Obstwiese",
|
||||
segment: "SCHWARMZEIT",
|
||||
intervalDays: 7,
|
||||
colonies: [],
|
||||
},
|
||||
],
|
||||
suggestionsOpen: 0,
|
||||
seasons: [],
|
||||
tasksVisible: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiarySeason", () => {
|
||||
it("shows the season with its source on the apiary page", async () => {
|
||||
renderSeason();
|
||||
|
||||
expect(await screen.findByText("Schwarmzeit · alle 7 Tage")).toBeInTheDocument();
|
||||
expect(screen.getByText("gemeldet am 18.04.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kirsche blüht")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names an estimated phase and a calendar phase by their source", async () => {
|
||||
vi.mocked(fetchPhenologyState).mockResolvedValue([
|
||||
state({ phaseSource: "GTS", reportedOn: null, phase: "APFEL" }),
|
||||
]);
|
||||
const { unmount } = renderSeason();
|
||||
expect(await screen.findByText("geschätzt")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
vi.mocked(fetchPhenologyState).mockResolvedValue([
|
||||
state({ phaseSource: "CALENDAR", reportedOn: null, phase: null }),
|
||||
]);
|
||||
renderSeason();
|
||||
expect(await screen.findByText("Kalender")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the season without an interval when the interval is unknown", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockRejectedValue(new Error("403"));
|
||||
renderSeason();
|
||||
|
||||
expect(await screen.findByText("Schwarmzeit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows „Pflanze blüht“ only with phenology:create and opens the sheet", async () => {
|
||||
renderSeason();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Pflanze blüht" }));
|
||||
|
||||
expect(await screen.findByRole("dialog")).toHaveTextContent("Entwicklung melden");
|
||||
expect(screen.getByRole("button", { name: "Salweide blüht" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the button without phenology:create", async () => {
|
||||
auth.permissions = new Set();
|
||||
renderSeason();
|
||||
|
||||
await screen.findByText("Schwarmzeit · alle 7 Tage");
|
||||
expect(screen.queryByRole("button", { name: "Pflanze blüht" })).toBeNull();
|
||||
});
|
||||
|
||||
it("explains a missing weather basis in words", async () => {
|
||||
vi.mocked(fetchPhenologyState).mockResolvedValue([
|
||||
state({
|
||||
phaseSource: "CALENDAR",
|
||||
reportedOn: null,
|
||||
note: "WEATHER_STALE",
|
||||
}),
|
||||
]);
|
||||
renderSeason();
|
||||
|
||||
expect(await screen.findByText("Wetterdaten veraltet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the sheet after a report and returns the focus to „Pflanze blüht“", async () => {
|
||||
vi.mocked(createPhenologyReport).mockResolvedValue({} as never);
|
||||
renderSeason();
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "Pflanze blüht",
|
||||
});
|
||||
await userEvent.click(trigger);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Salweide blüht" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Flower2 } from "lucide-react";
|
||||
import { SeasonChip } from "@/components/autopilot/season-chip";
|
||||
import { PhenologyReportSheet } from "@/components/phenology/phenology-report-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchPhenologyState, phenologyStateKey } from "@/lib/phenology/api";
|
||||
import { PHASE_REPORT_LABELS, SEASON_NOTE_LABELS, phaseSourceText } from "@/lib/phenology/labels";
|
||||
|
||||
interface ApiarySeasonProps {
|
||||
apiary: { id: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The season of an apiary with where it comes from („Schwarmzeit · alle 7
|
||||
* Tage · gemeldet am 18.04.“), and the way to report a blooming plant.
|
||||
*/
|
||||
export function ApiarySeason({ apiary }: ApiarySeasonProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const [isSheetOpen, setSheetOpen] = useState(false);
|
||||
const stateQuery = useQuery({
|
||||
queryKey: phenologyStateKey,
|
||||
queryFn: fetchPhenologyState,
|
||||
});
|
||||
// The interval is only known for apiaries with colonies to inspect; without
|
||||
// it (or without the right to see it) the chip names just the season.
|
||||
const todayQuery = useQuery({
|
||||
queryKey: autopilotTodayKey,
|
||||
queryFn: fetchAutopilotToday,
|
||||
});
|
||||
|
||||
const state = stateQuery.data?.find((entry) => entry.apiaryId === apiary.id);
|
||||
const intervalDays =
|
||||
todayQuery.data?.inspectionDue.find((entry) => entry.apiaryId === apiary.id)?.intervalDays ??
|
||||
null;
|
||||
const source = state ? phaseSourceText(state.phaseSource, state.reportedOn) : null;
|
||||
const canReport = hasPermission("phenology:create");
|
||||
|
||||
if (!state && !canReport) return null;
|
||||
|
||||
return (
|
||||
<section aria-label="Saison" className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{state ? (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<SeasonChip segment={state.segment} intervalDays={intervalDays} />
|
||||
{state.phase ? <span>{PHASE_REPORT_LABELS[state.phase]}</span> : null}
|
||||
{source ? (
|
||||
<>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">{source}</span>
|
||||
</>
|
||||
) : null}
|
||||
{state.note ? (
|
||||
<span className="text-warning-foreground">{SEASON_NOTE_LABELS[state.note]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canReport ? (
|
||||
<>
|
||||
<Button type="button" size="touch" variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
<Flower2 aria-hidden />
|
||||
Pflanze blüht
|
||||
</Button>
|
||||
<PhenologyReportSheet open={isSheetOpen} onOpenChange={setSheetOpen} apiary={apiary} />
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { berlinCalendarDay } from "@/lib/dates";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import {
|
||||
createPhenologyReport,
|
||||
fetchPhenologyReports,
|
||||
queuePhenologyReport,
|
||||
revokePhenologyReport,
|
||||
} from "@/lib/phenology/api";
|
||||
import type { PhenologyReport } from "@/lib/phenology/types";
|
||||
import { PhenologyReportSheet } from "./phenology-report-sheet";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/phenology/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/phenology/api")>()),
|
||||
createPhenologyReport: vi.fn(),
|
||||
fetchPhenologyReports: vi.fn(),
|
||||
queuePhenologyReport: vi.fn(),
|
||||
revokePhenologyReport: vi.fn(),
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ permissions: new Set<string>() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useCurrentUserId: () => "user-1",
|
||||
useAuth: () => ({
|
||||
hasPermission: (permission: string) => auth.permissions.has(permission),
|
||||
}),
|
||||
}));
|
||||
|
||||
const apiary = { id: "a-1", name: "Obstwiese" };
|
||||
|
||||
function report(overrides: Partial<PhenologyReport>): PhenologyReport {
|
||||
return {
|
||||
id: "r-1",
|
||||
apiaryId: null,
|
||||
apiaryName: null,
|
||||
phase: "SALWEIDE",
|
||||
date: "2026-03-20",
|
||||
notes: null,
|
||||
userId: "user-2",
|
||||
userName: "Karla",
|
||||
revokedAt: null,
|
||||
createdAt: "2026-03-20T08:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSheet(onOpenChange = vi.fn()) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PhenologyReportSheet open onOpenChange={onOpenChange} apiary={apiary} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onOpenChange;
|
||||
}
|
||||
|
||||
const today = () => berlinCalendarDay(new Date().toISOString());
|
||||
|
||||
beforeEach(() => {
|
||||
auth.permissions = new Set(["phenology:create", "phenology:read", "phenology:update"]);
|
||||
vi.mocked(createPhenologyReport).mockReset().mockResolvedValue(report({}));
|
||||
vi.mocked(fetchPhenologyReports).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(queuePhenologyReport).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(revokePhenologyReport)
|
||||
.mockReset()
|
||||
.mockResolvedValue(report({ revokedAt: "x" }));
|
||||
vi.mocked(toast.success).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
});
|
||||
|
||||
describe("PhenologyReportSheet", () => {
|
||||
it("one tap reports the phase for today for all apiaries", async () => {
|
||||
const onOpenChange = renderSheet();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Salweide blüht" }));
|
||||
|
||||
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(1));
|
||||
expect(createPhenologyReport).toHaveBeenCalledWith(
|
||||
{ phase: "SALWEIDE", date: today(), apiaryId: null },
|
||||
{ idempotencyKey: expect.any(String) },
|
||||
);
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Salweide blüht gemeldet"));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("rows are 56 px and „für alle Stände“ is preselected", () => {
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Für alle Stände" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: "Nur Obstwiese" })).not.toBeChecked();
|
||||
const rows = screen.getAllByRole("button", {
|
||||
name: /(blüht|vorbei)$|^Winterruhe$/,
|
||||
});
|
||||
expect(rows).toHaveLength(8);
|
||||
for (const row of rows) {
|
||||
expect(row).toHaveClass("min-h-14");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports for this apiary only after switching the scope", async () => {
|
||||
renderSheet();
|
||||
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Nur Obstwiese" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Kirsche blüht" }));
|
||||
|
||||
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createPhenologyReport).mock.calls[0][0]).toMatchObject({
|
||||
phase: "KIRSCHE",
|
||||
apiaryId: "a-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a different key for a different phase in the same opened sheet", async () => {
|
||||
vi.mocked(createPhenologyReport).mockRejectedValue(new ApiError(500, "Fehler", {}));
|
||||
renderSheet();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Salweide blüht" }));
|
||||
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(1));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Apfel blüht" }));
|
||||
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(2));
|
||||
|
||||
const [first, second] = vi.mocked(createPhenologyReport).mock.calls;
|
||||
expect(first[1]?.idempotencyKey).not.toBe(second[1]?.idempotencyKey);
|
||||
});
|
||||
|
||||
it("offline the report is queued and synced later", async () => {
|
||||
vi.mocked(createPhenologyReport).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const onOpenChange = renderSheet();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Holunder blüht" }));
|
||||
|
||||
await waitFor(() => expect(queuePhenologyReport).toHaveBeenCalledTimes(1));
|
||||
const [input, key, owner] = vi.mocked(queuePhenologyReport).mock.calls[0];
|
||||
expect(input).toEqual({ phase: "HOLUNDER", date: today(), apiaryId: null });
|
||||
expect(key).toBe(vi.mocked(createPhenologyReport).mock.calls[0][1]?.idempotencyKey);
|
||||
expect(owner).toBe("user-1");
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"Holunder blüht gespeichert – wird gesendet, sobald wieder Verbindung besteht",
|
||||
),
|
||||
);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("shows the reason of the server and stays open when the report is rejected", async () => {
|
||||
vi.mocked(createPhenologyReport).mockRejectedValue(
|
||||
new ApiError(400, "Das Datum liegt in der Zukunft.", {}),
|
||||
);
|
||||
const onOpenChange = renderSheet();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Linde blüht" }));
|
||||
|
||||
expect(await screen.findByText("Das Datum liegt in der Zukunft.")).toBeVisible();
|
||||
expect(queuePhenologyReport).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("lists this year's reports that count for the apiary and withdraws one", async () => {
|
||||
vi.mocked(fetchPhenologyReports).mockResolvedValue([
|
||||
report({ id: "r-1", phase: "SALWEIDE", date: "2026-03-20" }),
|
||||
report({
|
||||
id: "r-2",
|
||||
phase: "KIRSCHE",
|
||||
date: "2026-04-10",
|
||||
apiaryId: "a-1",
|
||||
apiaryName: "Obstwiese",
|
||||
userName: "Nick",
|
||||
}),
|
||||
report({
|
||||
id: "r-3",
|
||||
phase: "APFEL",
|
||||
date: "2026-04-20",
|
||||
apiaryId: "a-9",
|
||||
apiaryName: "Wald",
|
||||
}),
|
||||
report({
|
||||
id: "r-4",
|
||||
phase: "LINDE",
|
||||
date: "2026-06-01",
|
||||
revokedAt: "2026-06-02T08:00:00.000Z",
|
||||
}),
|
||||
]);
|
||||
renderSheet();
|
||||
|
||||
const list = await screen.findByRole("list", {
|
||||
name: "Meldungen dieses Jahres",
|
||||
});
|
||||
const items = within(list).getAllByRole("listitem");
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]).toHaveTextContent("Salweide blüht");
|
||||
expect(items[0]).toHaveTextContent("20.03.");
|
||||
expect(items[0]).toHaveTextContent("alle Stände");
|
||||
expect(items[1]).toHaveTextContent("Kirsche blüht");
|
||||
expect(items[1]).toHaveTextContent("Obstwiese");
|
||||
expect(items[2]).toHaveTextContent("zurückgezogen");
|
||||
expect(within(items[2]).queryByRole("button", { name: /Zurückziehen/ })).toBeNull();
|
||||
|
||||
await userEvent.click(within(items[1]).getByRole("button", { name: /Zurückziehen/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(revokePhenologyReport).toHaveBeenCalledWith("r-2", {
|
||||
idempotencyKey: expect.any(String),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Meldung zurückgezogen"));
|
||||
});
|
||||
|
||||
it("does not load the list without phenology:read", () => {
|
||||
auth.permissions = new Set(["phenology:create"]);
|
||||
renderSheet();
|
||||
|
||||
expect(screen.queryByRole("list", { name: "Meldungen dieses Jahres" })).toBeNull();
|
||||
expect(fetchPhenologyReports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers „Zurückziehen“ only with phenology:update", async () => {
|
||||
auth.permissions = new Set(["phenology:create", "phenology:read"]);
|
||||
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({})]);
|
||||
renderSheet();
|
||||
|
||||
await screen.findByRole("list", { name: "Meldungen dieses Jahres" });
|
||||
expect(screen.queryByRole("button", { name: /Zurückziehen/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("switches off „Zurückziehen“ with „Nur mit Verbindung“ offline", async () => {
|
||||
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({})]);
|
||||
renderSheet();
|
||||
const withdraw = await screen.findByRole("button", {
|
||||
name: /zurückziehen/i,
|
||||
});
|
||||
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
|
||||
expect(withdraw).toBeDisabled();
|
||||
expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument();
|
||||
});
|
||||
it("asks first before withdrawing a report for all apiaries", async () => {
|
||||
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
|
||||
renderSheet();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "Für alle Stände zurückziehen: Salweide blüht vom 20.03.",
|
||||
}),
|
||||
);
|
||||
expect(revokePhenologyReport).not.toHaveBeenCalled();
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "Für alle Stände zurückziehen?",
|
||||
});
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Zurückziehen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(revokePhenologyReport).toHaveBeenCalledWith("r-1", {
|
||||
idempotencyKey: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not withdraw when the question is cancelled", async () => {
|
||||
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
|
||||
renderSheet();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: /Für alle Stände zurückziehen/,
|
||||
}),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "Für alle Stände zurückziehen?",
|
||||
});
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(revokePhenologyReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the focus to the list heading after a withdrawal", async () => {
|
||||
vi.mocked(fetchPhenologyReports).mockResolvedValue([
|
||||
report({ id: "r-2", apiaryId: "a-1", apiaryName: "Obstwiese" }),
|
||||
]);
|
||||
renderSheet();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Zurückziehen/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("heading", { name: "Meldungen dieses Jahres" })).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("has a neutral heading", () => {
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Entwicklung melden" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes with Escape", async () => {
|
||||
const onOpenChange = renderSheet();
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("stays open while the report is on its way", async () => {
|
||||
let answer: (saved: PhenologyReport) => void = () => undefined;
|
||||
vi.mocked(createPhenologyReport).mockReturnValue(
|
||||
new Promise<PhenologyReport>((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
const onOpenChange = renderSheet();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Salweide blüht" }));
|
||||
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalled());
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Schließen" })).toBeDisabled();
|
||||
|
||||
await act(async () => answer(report({})));
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
});
|
||||
|
||||
it("says „Nur mit Verbindung“ instead of a load error offline", async () => {
|
||||
vi.mocked(fetchPhenologyReports).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderSheet();
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
|
||||
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Die Meldungen konnten nicht geladen werden.")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["summer time", "2026-07-10T23:30:00.000Z", "2026-07-11"],
|
||||
["winter time", "2026-01-10T23:30:00.000Z", "2026-01-11"],
|
||||
])("reports the Berlin day at 23:30 UTC in %s", async (_name, now, berlinDay) => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(now));
|
||||
renderSheet();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Linde blüht" }));
|
||||
|
||||
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createPhenologyReport).mock.calls[0][0].date).toBe(berlinDay);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Flower2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { autopilotTodayKey } from "@/lib/autopilot/api";
|
||||
import { formatDayMonth } from "@/lib/autopilot/format";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { berlinCalendarDay } from "@/lib/dates";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
|
||||
import {
|
||||
createPhenologyReport,
|
||||
fetchPhenologyReports,
|
||||
phenologyReportsKey,
|
||||
queuePhenologyReport,
|
||||
revokePhenologyReport,
|
||||
} from "@/lib/phenology/api";
|
||||
import { PHASE_REPORT_LABELS } from "@/lib/phenology/labels";
|
||||
import {
|
||||
PHENOLOGY_PHASES,
|
||||
type CreatePhenologyReportInput,
|
||||
type PhenologyReport,
|
||||
} from "@/lib/phenology/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PhenologyReportSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
apiary: { id: string; name: string };
|
||||
}
|
||||
|
||||
type Scope = "all" | "apiary";
|
||||
|
||||
const QUEUED_TEXT = "gespeichert – wird gesendet, sobald wieder Verbindung besteht";
|
||||
|
||||
/** Which plant is in bloom? One tap reports it for today. */
|
||||
export function PhenologyReportSheet({ open, onOpenChange, apiary }: PhenologyReportSheetProps) {
|
||||
// While a report or a withdrawal is on its way the sheet stays open, so the
|
||||
// answer (or the server's reason) is never lost behind a closed sheet.
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next && isBusy) return;
|
||||
onOpenChange(next);
|
||||
}
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)"
|
||||
>
|
||||
{/* Remounted per opening: a fresh idempotency key and the preselected scope. */}
|
||||
{open ? (
|
||||
<ReportForm
|
||||
apiary={apiary}
|
||||
onOpenChange={onOpenChange}
|
||||
isBusy={isBusy}
|
||||
onBusyChange={setBusy}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportForm({
|
||||
apiary,
|
||||
onOpenChange,
|
||||
isBusy,
|
||||
onBusyChange,
|
||||
}: Pick<PhenologyReportSheetProps, "apiary" | "onOpenChange"> & {
|
||||
isBusy: boolean;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [scope, setScope] = useState<Scope>("all");
|
||||
const capture = useCaptureMutation<CreatePhenologyReportInput, unknown>({
|
||||
create: createPhenologyReport,
|
||||
queue: queuePhenologyReport,
|
||||
failureMessage: "Die Meldung konnte nicht gespeichert werden.",
|
||||
// Each phase and scope is its own report and needs its own key.
|
||||
keyPerSubmission: true,
|
||||
onSaved: (outcome, input) => {
|
||||
const label = PHASE_REPORT_LABELS[input.phase];
|
||||
if (outcome.via === "online") {
|
||||
toast.success(`${label} gemeldet`);
|
||||
void queryClient.invalidateQueries({ queryKey: ["phenology"] });
|
||||
void queryClient.invalidateQueries({ queryKey: autopilotTodayKey });
|
||||
} else {
|
||||
toast.success(`${label} ${QUEUED_TEXT}`);
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
const [revokeError, setRevokeError] = useState<string | null>(null);
|
||||
const [isRevoking, setRevoking] = useState(false);
|
||||
const status = capture.submitError ?? revokeError;
|
||||
const busy = capture.isPending || isRevoking;
|
||||
useEffect(() => {
|
||||
onBusyChange(busy);
|
||||
return () => onBusyChange(false);
|
||||
}, [busy, onBusyChange]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
<SheetHeader className="px-0">
|
||||
<SheetTitle>Entwicklung melden</SheetTitle>
|
||||
<SheetDescription>Ein Tipp meldet die Pflanze für heute.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScopeChoice apiaryName={apiary.name} scope={scope} onChange={setScope} />
|
||||
<div role="group" aria-label="Pflanze" className="flex flex-col gap-2">
|
||||
{PHENOLOGY_PHASES.map((phase) => (
|
||||
<button
|
||||
key={phase}
|
||||
type="button"
|
||||
disabled={capture.isPending}
|
||||
onClick={() =>
|
||||
capture.submit({
|
||||
phase,
|
||||
date: berlinCalendarDay(new Date().toISOString()),
|
||||
apiaryId: scope === "all" ? null : apiary.id,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"flex min-h-14 w-full items-center gap-3 rounded-xl border border-border bg-background px-4",
|
||||
"text-left text-base font-medium outline-none transition-colors",
|
||||
"hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<Flower2 className="size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
{PHASE_REPORT_LABELS[phase]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Always mounted, so a screen reader announces the text when it appears. */}
|
||||
<p role="status" aria-live="polite" className="min-h-5 text-sm text-destructive">
|
||||
{status}
|
||||
</p>
|
||||
<ReportsOfTheYear
|
||||
apiary={apiary}
|
||||
onRevokeError={setRevokeError}
|
||||
onRevokingChange={setRevoking}
|
||||
/>
|
||||
<SheetClose
|
||||
disabled={isBusy}
|
||||
render={<Button type="button" size="touch" variant="outline" />}
|
||||
>
|
||||
Schließen
|
||||
</SheetClose>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeChoice({
|
||||
apiaryName,
|
||||
scope,
|
||||
onChange,
|
||||
}: {
|
||||
apiaryName: string;
|
||||
scope: Scope;
|
||||
onChange: (scope: Scope) => void;
|
||||
}) {
|
||||
const options: { value: Scope; label: string }[] = [
|
||||
{ value: "all", label: "Für alle Stände" },
|
||||
{ value: "apiary", label: `Nur ${apiaryName}` },
|
||||
];
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="mb-1 text-sm font-medium">Gilt für</legend>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex min-h-11 items-center gap-3 rounded-lg px-1 text-base has-focus-visible:ring-3 has-focus-visible:ring-ring/50"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="phenology-scope"
|
||||
className="size-5 accent-primary"
|
||||
checked={scope === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** The reports that count for this apiary: its own and those for all apiaries. */
|
||||
function appliesTo(report: PhenologyReport, apiaryId: string): boolean {
|
||||
return report.apiaryId === null || report.apiaryId === apiaryId;
|
||||
}
|
||||
|
||||
function ReportsOfTheYear({
|
||||
apiary,
|
||||
onRevokeError,
|
||||
onRevokingChange,
|
||||
}: {
|
||||
apiary: { id: string };
|
||||
onRevokeError: (message: string | null) => void;
|
||||
onRevokingChange: (isRevoking: boolean) => void;
|
||||
}) {
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const year = Number(berlinCalendarDay(new Date().toISOString()).slice(0, 4));
|
||||
const canRead = hasPermission("phenology:read");
|
||||
const canWithdraw = hasPermission("phenology:update");
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
const reports = useQuery({
|
||||
queryKey: phenologyReportsKey(year),
|
||||
queryFn: () => fetchPhenologyReports(year),
|
||||
enabled: canRead,
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: (id: string) => revokePhenologyReport(id, { idempotencyKey: newIdempotencyKey() }),
|
||||
onMutate: () => onRevokeError(null),
|
||||
onSuccess: () => {
|
||||
toast.success("Meldung zurückgezogen");
|
||||
void queryClient.invalidateQueries({ queryKey: ["phenology"] });
|
||||
void queryClient.invalidateQueries({ queryKey: autopilotTodayKey });
|
||||
heading.current?.focus();
|
||||
},
|
||||
onError: (error) =>
|
||||
onRevokeError(errorMessage(error, "Die Meldung konnte nicht zurückgezogen werden.")),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onRevokingChange(revoke.isPending);
|
||||
}, [revoke.isPending, onRevokingChange]);
|
||||
|
||||
if (!canRead) return null;
|
||||
const items = (reports.data ?? []).filter((report) => appliesTo(report, apiary.id));
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-2" aria-labelledby="phenology-reports-heading">
|
||||
<h3
|
||||
id="phenology-reports-heading"
|
||||
ref={heading}
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium outline-none"
|
||||
>
|
||||
Meldungen dieses Jahres
|
||||
</h3>
|
||||
{reports.isError && !isOffline ? (
|
||||
<p className="text-sm text-destructive">Die Meldungen konnten nicht geladen werden.</p>
|
||||
) : null}
|
||||
{reports.isSuccess && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Noch keine Meldung in diesem Jahr.</p>
|
||||
) : null}
|
||||
{items.length > 0 ? (
|
||||
<ul aria-label="Meldungen dieses Jahres" className="flex flex-col gap-2">
|
||||
{items.map((report) => (
|
||||
<ReportItem
|
||||
key={report.id}
|
||||
report={report}
|
||||
canWithdraw={canWithdraw}
|
||||
disabled={isOffline || revoke.isPending}
|
||||
describedBy={describedBy}
|
||||
onWithdraw={() => revoke.mutate(report.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportItem({
|
||||
report,
|
||||
canWithdraw,
|
||||
disabled,
|
||||
describedBy,
|
||||
onWithdraw,
|
||||
}: {
|
||||
report: PhenologyReport;
|
||||
canWithdraw: boolean;
|
||||
disabled: boolean;
|
||||
describedBy: string | undefined;
|
||||
onWithdraw: () => void;
|
||||
}) {
|
||||
const label = PHASE_REPORT_LABELS[report.phase];
|
||||
const day = formatDayMonth(report.date);
|
||||
const isRevoked = report.revokedAt !== null;
|
||||
const isForAll = report.apiaryId === null;
|
||||
const withdrawLabel = `${label} vom ${day}`;
|
||||
return (
|
||||
<li className="flex min-h-14 items-center justify-between gap-3 rounded-xl border border-border px-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className={cn("font-medium", isRevoked && "text-muted-foreground line-through")}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{day} · {report.apiaryName ?? "alle Stände"} · {report.userName}
|
||||
{isRevoked ? " · zurückgezogen" : ""}
|
||||
</p>
|
||||
</div>
|
||||
{canWithdraw && !isRevoked && isForAll ? (
|
||||
// A report for all apiaries changes the season of every apiary: ask first.
|
||||
<ConfirmDialog
|
||||
triggerLabel="Für alle Stände zurückziehen"
|
||||
triggerAriaLabel={`Für alle Stände zurückziehen: ${withdrawLabel}`}
|
||||
triggerSize="touch"
|
||||
triggerDisabled={disabled}
|
||||
title="Für alle Stände zurückziehen?"
|
||||
description={`${withdrawLabel} gilt für alle Stände. Beim Zurückziehen wird die Saison aller Stände neu berechnet.`}
|
||||
confirmLabel="Zurückziehen"
|
||||
onConfirm={onWithdraw}
|
||||
/>
|
||||
) : null}
|
||||
{canWithdraw && !isRevoked && !isForAll ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
aria-label={`Zurückziehen: ${withdrawLabel}`}
|
||||
onClick={onWithdraw}
|
||||
>
|
||||
Zurückziehen
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface PrefillStatusProps {
|
||||
/** The prefill is asked for at all (something is chosen). */
|
||||
enabled: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isRetrying: boolean;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the prefill is doing, so that a missing warning never reads as "no
|
||||
* warning": while loading a short line, after a failure a note that stays
|
||||
* until a retry works. Saving stays possible - the server checks anyway. The
|
||||
* region stays mounted so the change is announced.
|
||||
*/
|
||||
export function PrefillStatus({ enabled, isLoading, isError, isRetrying, onRetry }: PrefillStatusProps) {
|
||||
return (
|
||||
<div role="status" aria-live="polite">
|
||||
{enabled && isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Hinweise werden geladen …</p>
|
||||
) : null}
|
||||
{enabled && isError ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-warning/20 px-3 py-2 text-sm">
|
||||
<span>Hinweise (z. B. Wartezeit) konnten nicht geladen werden.</span>
|
||||
<Button type="button" variant="outline" size="touch" onClick={onRetry} disabled={isRetrying}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user