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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 22:47:17 +02:00
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>
);
}