feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -99,6 +100,23 @@ describe("ApiaryColoniesCard", () => {
|
||||
expect(screen.getByRole("button", { name: "Runde starten" })).not.toHaveAttribute("href");
|
||||
});
|
||||
|
||||
it("offers „Für offline laden“ in the colonies card", async () => {
|
||||
vi.mocked(fetchColonyOverview).mockResolvedValue(overview([row({ id: "c-1", code: "A1" })]));
|
||||
renderCard();
|
||||
|
||||
const heading = await screen.findByRole("heading", { name: "Völker (1)" });
|
||||
const card = heading.closest("[data-slot=card]") as HTMLElement;
|
||||
expect(within(card).getByRole("button", { name: "Für offline laden" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks for the overview with the signed-in user, so it can answer from the pack offline", async () => {
|
||||
vi.mocked(fetchColonyOverview).mockResolvedValue(overview([]));
|
||||
renderCard();
|
||||
|
||||
await screen.findByText(/Noch keine Völker/);
|
||||
expect(fetchColonyOverview).toHaveBeenCalledWith("a-1", "user-1");
|
||||
});
|
||||
|
||||
it("shows a retryable error instead of an empty list when loading fails", async () => {
|
||||
vi.mocked(fetchColonyOverview).mockRejectedValue(new Error("boom"));
|
||||
renderCard();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Play, Sprout } from "lucide-react";
|
||||
import { useColonyOverview } from "@/lib/apiaries/use-colony-overview";
|
||||
import type { ColonyOverview } from "@/lib/apiaries/field-types";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels";
|
||||
import { colonyStatusLight } from "@/lib/colonies/status-light";
|
||||
@@ -10,6 +11,7 @@ import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { startRound } from "@/lib/rounds/round-state";
|
||||
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { LoadPackButton } from "@/components/offline/load-pack-button";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -52,10 +54,49 @@ export function StartRoundButton({ apiary }: { apiary: ApiaryRef }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface ColonyOverviewLinksProps {
|
||||
overview: ColonyOverview;
|
||||
/** Where a colony row leads - the colony page, or its offline view. */
|
||||
hrefFor: (colonyId: string) => string;
|
||||
}
|
||||
|
||||
/** One row per living colony: code, traffic light (icon and word), status and reasons. */
|
||||
export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksProps) {
|
||||
const today = todayLocalIsoDate();
|
||||
return overview.rows.map((row) => {
|
||||
const { light, reasons } = colonyStatusLight(row, {
|
||||
today,
|
||||
inspectionsVisible: overview.inspectionsVisible,
|
||||
});
|
||||
return (
|
||||
<Link
|
||||
key={row.id}
|
||||
href={hrefFor(row.id)}
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{row.code}</span>
|
||||
<StatusLightBadge light={light} />
|
||||
<span className="text-sm text-muted-foreground">{COLONY_STATUS_LABELS[row.status]}</span>
|
||||
</span>
|
||||
{reasons.length > 0 ? (
|
||||
<span className="text-sm text-muted-foreground">{reasons.join(" · ")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function colonyPageHref(colonyId: string): string {
|
||||
return `/colonies/${colonyId}`;
|
||||
}
|
||||
|
||||
export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) {
|
||||
const query = useColonyOverview(apiary.id);
|
||||
const overview = query.data;
|
||||
const today = todayLocalIsoDate();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -76,31 +117,10 @@ export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) {
|
||||
description="Sobald ein Volk hier steht, erscheint es mit seiner Ampel."
|
||||
/>
|
||||
) : null}
|
||||
{overview?.rows.map((row) => {
|
||||
const { light, reasons } = colonyStatusLight(row, {
|
||||
today,
|
||||
inspectionsVisible: overview.inspectionsVisible,
|
||||
});
|
||||
return (
|
||||
<Link
|
||||
key={row.id}
|
||||
href={`/colonies/${row.id}`}
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{row.code}</span>
|
||||
<StatusLightBadge light={light} />
|
||||
<span className="text-sm text-muted-foreground">{COLONY_STATUS_LABELS[row.status]}</span>
|
||||
</span>
|
||||
{reasons.length > 0 ? (
|
||||
<span className="text-sm text-muted-foreground">{reasons.join(" · ")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{overview ? <ColonyOverviewLinks overview={overview} hrefFor={colonyPageHref} /> : null}
|
||||
<div className="mt-2">
|
||||
<LoadPackButton apiaryId={apiary.id} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import { EditTriggerButton } from "./edit-dialog-parts";
|
||||
|
||||
let sessionMode: "online" | "offline" = "online";
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useSessionMode: () => sessionMode }));
|
||||
|
||||
describe("EditTriggerButton", () => {
|
||||
afterEach(() => {
|
||||
sessionMode = "online";
|
||||
reportRequestOutcome("reached-server");
|
||||
});
|
||||
|
||||
it("is a normal „Bearbeiten“ button while online", () => {
|
||||
render(<EditTriggerButton />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Bearbeiten" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("is disabled offline and says why", () => {
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
render(<EditTriggerButton />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Bearbeiten braucht Netz" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("is disabled in an offline session even when the network looks fine", () => {
|
||||
sessionMode = "offline";
|
||||
render(<EditTriggerButton />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Bearbeiten braucht Netz" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("stays disabled when the caller disables it, and keeps the caller's other props", () => {
|
||||
render(<EditTriggerButton disabled data-testid="edit" />);
|
||||
|
||||
expect(screen.getByTestId("edit")).toBeDisabled();
|
||||
expect(screen.getByTestId("edit")).toHaveTextContent("Bearbeiten");
|
||||
});
|
||||
|
||||
it("wraps its text on a narrow screen instead of overflowing", () => {
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
render(<EditTriggerButton />);
|
||||
|
||||
expect(screen.getByRole("button")).toHaveClass("h-auto", "whitespace-normal");
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,33 @@
|
||||
import { ComponentProps } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSessionMode } from "@/lib/auth/auth-context";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The "Bearbeiten" trigger of every record in a list: at least 40 px high on
|
||||
* a phone, so it can be hit with a gloved thumb.
|
||||
* a phone, so it can be hit with a gloved thumb. Saved records can only be
|
||||
* changed with a connection (edits are not queued), so offline - or in an
|
||||
* offline session without a token - it is disabled and says why.
|
||||
*/
|
||||
export function EditTriggerButton(props: ComponentProps<typeof Button>) {
|
||||
export function EditTriggerButton({
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof Button>) {
|
||||
const connectivity = useConnectivity();
|
||||
const sessionMode = useSessionMode();
|
||||
const needsNetwork = connectivity === "offline" || sessionMode === "offline";
|
||||
return (
|
||||
<Button variant="ghost" {...props}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
{...props}
|
||||
disabled={disabled || needsNetwork}
|
||||
className={cn(needsNetwork && "h-auto whitespace-normal text-left", className)}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
Bearbeiten
|
||||
{needsNetwork ? "Bearbeiten braucht Netz" : "Bearbeiten"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { createFeeding } from "@/lib/feedings/api";
|
||||
import type { FeedType } from "@/lib/feedings/types";
|
||||
import type { BulkCreateFeedingInput } from "@/lib/bulk/api";
|
||||
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
|
||||
@@ -13,6 +15,10 @@ export const FEEDING_UNIT_OPTIONS: { value: string; label: string }[] = [
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface FeedingFormValues {
|
||||
colonyId: string;
|
||||
/** One colony, or the same feeding for several. */
|
||||
target: CaptureTarget;
|
||||
bulkApiaryId: string | null;
|
||||
bulkColonyIds: string[];
|
||||
date: string;
|
||||
feedType: FeedType;
|
||||
amount: string;
|
||||
@@ -25,6 +31,9 @@ export interface FeedingFormValues {
|
||||
export function initialFeedingValues(colonyId: string): FeedingFormValues {
|
||||
return {
|
||||
colonyId,
|
||||
target: "single",
|
||||
bulkApiaryId: null,
|
||||
bulkColonyIds: [],
|
||||
date: todayLocalIsoDate(),
|
||||
feedType: "SUGAR_WATER",
|
||||
amount: "",
|
||||
@@ -35,11 +44,9 @@ export function initialFeedingValues(colonyId: string): FeedingFormValues {
|
||||
};
|
||||
}
|
||||
|
||||
export function toFeedingInput(
|
||||
values: FeedingFormValues,
|
||||
): Parameters<typeof createFeeding>[0] {
|
||||
/** Everything of the feeding that is the same for one colony or for many. */
|
||||
function sharedFeedingFields(values: FeedingFormValues) {
|
||||
return {
|
||||
colonyId: values.colonyId,
|
||||
date: toApiDate(values.date),
|
||||
feedType: values.feedType,
|
||||
amount: Number(values.amount),
|
||||
@@ -49,3 +56,14 @@ export function toFeedingInput(
|
||||
notes: emptyToNull(values.notes),
|
||||
};
|
||||
}
|
||||
|
||||
export function toFeedingInput(
|
||||
values: FeedingFormValues,
|
||||
): Parameters<typeof createFeeding>[0] {
|
||||
return { colonyId: values.colonyId, ...sharedFeedingFields(values) };
|
||||
}
|
||||
|
||||
/** One feeding for the chosen colonies; the batch number is what the user typed. */
|
||||
export function toBulkFeedingInput(values: FeedingFormValues): BulkCreateFeedingInput {
|
||||
return { ...sharedFeedingFields(values), colonyIds: values.bulkColonyIds };
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewFeedingPage from "./page";
|
||||
import { toast } from "sonner";
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { createFeedingsBulk } from "@/lib/bulk/api";
|
||||
import { queueBulk } from "@/lib/offline/db";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
|
||||
const push = vi.fn();
|
||||
@@ -13,7 +17,19 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/feedings/api", () => ({ createFeeding: vi.fn() }));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueFeeding: vi.fn() }));
|
||||
vi.mock("@/lib/bulk/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/bulk/api")>()),
|
||||
createFeedingsBulk: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn().mockResolvedValue([]),
|
||||
refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueFeeding: vi.fn(), queueBulk: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
@@ -34,6 +50,11 @@ describe("NewFeedingPage", () => {
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
|
||||
] as never);
|
||||
vi.mocked(createFeeding).mockReset();
|
||||
vi.mocked(createFeedingsBulk).mockReset();
|
||||
vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(fetchApiaries)
|
||||
.mockReset()
|
||||
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
|
||||
});
|
||||
|
||||
it("marks what is required and keeps the extra fields folded", () => {
|
||||
@@ -86,4 +107,65 @@ describe("NewFeedingPage", () => {
|
||||
expect(screen.getByLabelText(/^Menge/)).toHaveFocus();
|
||||
expect(createFeeding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("for several colonies", () => {
|
||||
const BULK_COLONIES = [
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
|
||||
{ id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
|
||||
{ id: "colony-3", code: "V-03", status: "QUEENLESS", apiaryId: "apiary-1" },
|
||||
];
|
||||
|
||||
async function fillBulkForm() {
|
||||
vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never);
|
||||
renderPage();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
|
||||
await screen.findByText("3 von 3 Völkern gewählt");
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "V-02" }));
|
||||
await userEvent.type(screen.getByLabelText(/^Menge/), "2");
|
||||
}
|
||||
|
||||
it("bulk mode sends colonyIds with the shared feeding", async () => {
|
||||
vi.mocked(createFeedingsBulk).mockResolvedValue({ bulkId: "b-1", records: [] } as never);
|
||||
await fillBulkForm();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Fütterung speichern" }));
|
||||
|
||||
await waitFor(() => expect(createFeedingsBulk).toHaveBeenCalledTimes(1));
|
||||
const [input, options] = vi.mocked(createFeedingsBulk).mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
colonyIds: ["colony-1", "colony-3"],
|
||||
feedType: "SUGAR_WATER",
|
||||
amount: 2,
|
||||
unit: "l",
|
||||
});
|
||||
expect(input).not.toHaveProperty("colonyId");
|
||||
expect(options?.idempotencyKey).toBeTruthy();
|
||||
expect(createFeeding).not.toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith("Fütterung für 2 Völker gespeichert");
|
||||
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
|
||||
});
|
||||
|
||||
it("offline queues exactly one bulk entry", async () => {
|
||||
vi.mocked(createFeedingsBulk).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await fillBulkForm();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Fütterung speichern" }));
|
||||
|
||||
await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(queueBulk).mock.calls[0][0]).toBe("feedings");
|
||||
expect(vi.mocked(queueBulk).mock.calls[0][1]).toMatchObject({
|
||||
colonyIds: ["colony-1", "colony-3"],
|
||||
});
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"));
|
||||
});
|
||||
|
||||
it("says the batch number counts for every chosen colony", async () => {
|
||||
await fillBulkForm();
|
||||
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
|
||||
|
||||
expect(screen.getByText(/Gilt für alle gewählten Völker\./)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,15 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Utensils } from "lucide-react";
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { createFeedingsBulk } from "@/lib/bulk/api";
|
||||
import {
|
||||
bulkDestination,
|
||||
bulkQueuedText,
|
||||
bulkSavedText,
|
||||
bulkSelectionError,
|
||||
} from "@/lib/bulk/capture-texts";
|
||||
import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
|
||||
import { useBulkCapture } from "@/lib/bulk/use-bulk-capture";
|
||||
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||
import { queueFeeding } from "@/lib/offline/db";
|
||||
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
|
||||
@@ -19,7 +28,8 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
|
||||
import { CaptureColonyField } from "@/components/form/capture-colony-field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
@@ -27,17 +37,27 @@ import {
|
||||
FEEDING_UNIT_OPTIONS,
|
||||
FeedingFormValues,
|
||||
initialFeedingValues,
|
||||
toBulkFeedingInput,
|
||||
toFeedingInput,
|
||||
} from "./feeding-form-values";
|
||||
|
||||
type RequiredKey = "colonyId" | "date" | "amount";
|
||||
type RequiredKey = "colonyId" | "bulkColonies" | "date" | "amount";
|
||||
type FieldErrors = Partial<Record<RequiredKey, string>>;
|
||||
|
||||
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date", "amount"];
|
||||
const SHARED_NOTE = "Gilt für alle gewählten Völker.";
|
||||
const BATCH_HINT = "Nummer der Futterpackung, meist „Ch.-B.“ oder „Lot“.";
|
||||
|
||||
function requiredKeys(values: FeedingFormValues): RequiredKey[] {
|
||||
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "amount"];
|
||||
}
|
||||
|
||||
function validate(values: FeedingFormValues): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
|
||||
if (values.target === "bulk") {
|
||||
errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds);
|
||||
} else if (!values.colonyId) {
|
||||
errors.colonyId = "Bitte ein Volk auswählen.";
|
||||
}
|
||||
if (!values.date) errors.date = "Bitte ein Datum angeben.";
|
||||
if (!values.amount.trim()) {
|
||||
errors.amount = "Bitte eine Menge angeben.";
|
||||
@@ -72,11 +92,33 @@ export default function NewFeedingPage() {
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
if ((REQUIRED_KEYS as string[]).includes(key)) {
|
||||
setFieldErrors((current) => ({ ...current, [key]: undefined }));
|
||||
}
|
||||
// The colonies of a bulk capture are one field in three values.
|
||||
const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key;
|
||||
setFieldErrors((current) => ({ ...current, [field]: undefined }));
|
||||
}
|
||||
|
||||
const isBulk = values.target === "bulk";
|
||||
const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null);
|
||||
|
||||
const bulkCapture = useBulkCapture({
|
||||
kind: "feedings",
|
||||
create: createFeedingsBulk,
|
||||
failureMessage: "Fütterung konnte nicht gespeichert werden.",
|
||||
onSaved: (outcome, input) => {
|
||||
draft.clear();
|
||||
const count = input.colonyIds.length;
|
||||
if (outcome.via === "queued") {
|
||||
toast.info(bulkQueuedText("Fütterung", count));
|
||||
} else {
|
||||
for (const colonyId of input.colonyIds) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "feedings"] });
|
||||
}
|
||||
toast.success(bulkSavedText("Fütterung", count));
|
||||
}
|
||||
router.push(bulkDestination(values.bulkApiaryId));
|
||||
},
|
||||
});
|
||||
|
||||
const capture = useCaptureMutation({
|
||||
create: createFeeding,
|
||||
queue: queueFeeding,
|
||||
@@ -106,20 +148,38 @@ export default function NewFeedingPage() {
|
||||
event.preventDefault();
|
||||
const errors = validate(values);
|
||||
setFieldErrors(errors);
|
||||
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||
const firstInvalid = requiredKeys(values).find((key) => errors[key]);
|
||||
if (firstInvalid) {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
if (isBulk) {
|
||||
bulkCapture.submit(toBulkFeedingInput(values));
|
||||
return;
|
||||
}
|
||||
capture.submit(toFeedingInput(values));
|
||||
}
|
||||
|
||||
/** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */
|
||||
function resendWithoutRefused() {
|
||||
const refused = new Set(bulkCapture.invalidColonyIds);
|
||||
const next = {
|
||||
...values,
|
||||
bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)),
|
||||
};
|
||||
setValues(next);
|
||||
if (next.bulkColonyIds.length > 0) {
|
||||
bulkCapture.submit(toBulkFeedingInput(next));
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
// A draft from before the bulk choice existed lacks its values.
|
||||
setValues({ ...initialFeedingValues(""), ...restored });
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
@@ -152,11 +212,21 @@ export default function NewFeedingPage() {
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Was, welches Volk, wann?">
|
||||
<LivingColonySelectField
|
||||
triggerId="feeding-colony"
|
||||
value={values.colonyId}
|
||||
onChange={(colonyId) => set("colonyId", colonyId)}
|
||||
error={fieldErrors.colonyId}
|
||||
<CaptureColonyField
|
||||
idPrefix="feeding"
|
||||
target={values.target}
|
||||
onTargetChange={(target) => set("target", target)}
|
||||
colonyId={values.colonyId}
|
||||
onColonyChange={(colonyId) => set("colonyId", colonyId)}
|
||||
colonyError={fieldErrors.colonyId}
|
||||
bulkApiaryId={values.bulkApiaryId}
|
||||
onBulkApiaryChange={(apiaryId) => {
|
||||
set("bulkApiaryId", apiaryId);
|
||||
set("bulkColonyIds", []);
|
||||
}}
|
||||
bulkColonyIds={values.bulkColonyIds}
|
||||
onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)}
|
||||
bulkError={fieldErrors.bulkColonies}
|
||||
/>
|
||||
<Field label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
@@ -237,7 +307,7 @@ export default function NewFeedingPage() {
|
||||
</Field>
|
||||
<Field
|
||||
label="Chargennummer"
|
||||
hint="Nummer der Futterpackung, meist „Ch.-B.“ oder „Lot“."
|
||||
hint={isBulk ? `${BATCH_HINT} ${SHARED_NOTE}` : BATCH_HINT}
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
@@ -262,7 +332,16 @@ export default function NewFeedingPage() {
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
capture.submitError ? (
|
||||
isBulk ? (
|
||||
<BulkSubmitNote
|
||||
submitError={bulkCapture.submitError}
|
||||
isRetryLater={bulkCapture.isRetryLater}
|
||||
invalidColonyIds={bulkCapture.invalidColonyIds}
|
||||
colonies={apiaryColonies.data?.items}
|
||||
isPending={bulkCapture.isPending}
|
||||
onResendWithout={resendWithoutRefused}
|
||||
/>
|
||||
) : capture.submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
@@ -275,9 +354,9 @@ export default function NewFeedingPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={capture.isPending}
|
||||
disabled={capture.isPending || bulkCapture.isPending}
|
||||
>
|
||||
{capture.isPending
|
||||
{capture.isPending || bulkCapture.isPending
|
||||
? "Wird gespeichert…"
|
||||
: "Fütterung speichern"}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -11,14 +11,15 @@ import type { DailyWeather } from "@/lib/weather/types";
|
||||
import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
|
||||
import { queueInspection } from "@/lib/offline/db";
|
||||
import type { StoredPhoto } from "@/lib/offline/db";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import {
|
||||
attachSessionToInspection,
|
||||
attachSessionToQueuedInspection,
|
||||
handOverSessionPhotos,
|
||||
discardSessionPhotos,
|
||||
listSessionPhotos,
|
||||
releaseSessionPhotos,
|
||||
moveDraftPhotos,
|
||||
queueInspectionWithPhotos,
|
||||
} from "@/lib/photos/store";
|
||||
import { DRAFT_PREFIX } from "@/lib/forms/draft-storage";
|
||||
import { initialInspectionValues } from "./inspection-form-values";
|
||||
@@ -47,16 +48,15 @@ vi.mock("@/lib/inspections/api", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueInspection: vi.fn() }));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
PHOTOS_PER_INSPECTION_MAX: 5,
|
||||
listSessionPhotos: vi.fn(),
|
||||
addDraftPhoto: vi.fn(),
|
||||
removePhoto: vi.fn(),
|
||||
attachSessionToInspection: vi.fn(),
|
||||
attachSessionToQueuedInspection: vi.fn(),
|
||||
releaseSessionPhotos: vi.fn(),
|
||||
handOverSessionPhotos: vi.fn(),
|
||||
queueInspectionWithPhotos: vi.fn(),
|
||||
moveDraftPhotos: vi.fn(),
|
||||
discardSessionPhotos: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -66,11 +66,17 @@ function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewInspectionPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...rendered, client };
|
||||
}
|
||||
|
||||
/** jsdom applies no CSS: a class that would take the element out of the accessibility tree. */
|
||||
function hidesWhileEmpty(element: HTMLElement): boolean {
|
||||
return [...element.classList].some((name) => /(^|:)hidden$/.test(name));
|
||||
}
|
||||
|
||||
describe("NewInspectionPage", () => {
|
||||
@@ -87,11 +93,12 @@ describe("NewInspectionPage", () => {
|
||||
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
|
||||
vi.mocked(toast.info).mockReset();
|
||||
vi.mocked(toast.success).mockReset();
|
||||
vi.mocked(toast.error).mockReset();
|
||||
permissions = new Set();
|
||||
vi.mocked(listSessionPhotos).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(attachSessionToInspection).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(attachSessionToQueuedInspection).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(releaseSessionPhotos).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(handOverSessionPhotos).mockReset().mockResolvedValue(0);
|
||||
vi.mocked(queueInspectionWithPhotos).mockReset().mockResolvedValue(0);
|
||||
vi.mocked(moveDraftPhotos).mockReset().mockResolvedValue({ moved: 0, discarded: 0 });
|
||||
vi.mocked(discardSessionPhotos).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
|
||||
});
|
||||
@@ -453,6 +460,15 @@ describe("NewInspectionPage", () => {
|
||||
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("keeps the weather announcement region present while it is empty", () => {
|
||||
renderPage();
|
||||
|
||||
// Hidden while empty, it would swallow the first announcement.
|
||||
const live = screen.getByRole("status");
|
||||
expect(live).toBeEmptyDOMElement();
|
||||
expect(hidesWhileEmpty(live)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in a stand round", () => {
|
||||
@@ -480,7 +496,6 @@ describe("NewInspectionPage", () => {
|
||||
overviewOf(["colony-1", "colony-2", "colony-3"]) as never,
|
||||
);
|
||||
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
|
||||
vi.mocked(queueInspection).mockReset();
|
||||
startRound("user-1", apiary, colonies);
|
||||
});
|
||||
|
||||
@@ -519,6 +534,21 @@ describe("NewInspectionPage", () => {
|
||||
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-2", "colony-3"]);
|
||||
});
|
||||
|
||||
it("does not bring back the draft of an inspection saved right after the last change", async () => {
|
||||
const draftKey = `${DRAFT_PREFIX}user-1:inspection-new`;
|
||||
renderPage();
|
||||
await screen.findByText("Runde Obstwiese: 1 von 3");
|
||||
|
||||
// Saved well within the draft's 600 ms delay; the form stays mounted behind the panel.
|
||||
await userEvent.type(screen.getByLabelText(/^Wetter/), "x");
|
||||
await save();
|
||||
expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument();
|
||||
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 800)));
|
||||
|
||||
// „Weiter mit B3“ would otherwise offer this saved entry as a draft.
|
||||
expect(window.localStorage.getItem(draftKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("after save shows „Weiter mit B3“ instead of going to the colony", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("Runde Obstwiese: 1 von 3");
|
||||
@@ -558,13 +588,12 @@ describe("NewInspectionPage", () => {
|
||||
|
||||
it("an offline save also advances the round", async () => {
|
||||
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(queueInspection).mockResolvedValue(undefined);
|
||||
renderPage();
|
||||
await screen.findByText("Runde Obstwiese: 1 von 3");
|
||||
await save();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument();
|
||||
expect(queueInspection).toHaveBeenCalled();
|
||||
expect(queueInspectionWithPhotos).toHaveBeenCalled();
|
||||
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-1"]);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -595,10 +624,25 @@ describe("NewInspectionPage", () => {
|
||||
|
||||
describe("photos", () => {
|
||||
const DRAFT_KEY = `${DRAFT_PREFIX}user-1:inspection-new`;
|
||||
const PHOTO: StoredPhoto = {
|
||||
id: "photo-1",
|
||||
sessionId: "any",
|
||||
ownerUserId: "user-1",
|
||||
blob: new Blob(["jpeg"], { type: "image/jpeg" }),
|
||||
fileName: "durchsicht-2026-10-13-1.jpg",
|
||||
createdAt: "2026-10-13T08:16:00.000Z",
|
||||
state: "draft",
|
||||
uploadIdempotencyKey: "upload-key-0000000001",
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom has no object URLs; the thumbnails only need a string.
|
||||
URL.createObjectURL = vi.fn(() => "blob:photo");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
permissions = new Set(["documents:upload"]);
|
||||
vi.mocked(queueInspection).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -621,6 +665,19 @@ describe("NewInspectionPage", () => {
|
||||
);
|
||||
}
|
||||
|
||||
/** Types into the form and waits until its draft is on the device. */
|
||||
async function typeUntilDrafted() {
|
||||
await userEvent.type(screen.getByLabelText(/^Wetter/), "sonnig");
|
||||
await waitFor(() => expect(window.localStorage.getItem(DRAFT_KEY)).not.toBeNull(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Lets already runnable promise callbacks run. */
|
||||
function settle(): Promise<void> {
|
||||
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
|
||||
}
|
||||
@@ -635,52 +692,137 @@ describe("NewInspectionPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the photos to the saved inspection and sends them in the background", async () => {
|
||||
it("hands the photos to the saved inspection, sends them and refreshes what shows them", async () => {
|
||||
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
|
||||
renderPage();
|
||||
vi.mocked(handOverSessionPhotos).mockResolvedValue(2);
|
||||
vi.mocked(syncPendingEntries).mockResolvedValue({ synced: 2, failed: 0, skippedForeign: 0 });
|
||||
const { client } = renderPage();
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
|
||||
|
||||
await save();
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
expect(attachSessionToInspection).toHaveBeenCalledWith(shownSession(), "i-1");
|
||||
expect(vi.mocked(attachSessionToInspection).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
await waitFor(() =>
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["documents", "INSPECTION", "i-1"] }),
|
||||
);
|
||||
// The band's count and the inspection's document list.
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
expect(handOverSessionPhotos).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
sessionId: shownSession(),
|
||||
inspectionId: "i-1",
|
||||
});
|
||||
// A sync already running may have passed these photos: it runs once more.
|
||||
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true });
|
||||
expect(vi.mocked(handOverSessionPhotos).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(attachSessionToQueuedInspection).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(syncPendingEntries).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
invalidate.mock.invocationCallOrder[invalidate.mock.invocationCallOrder.length - 1],
|
||||
);
|
||||
expect(queueInspectionWithPhotos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ties the photos to the queued inspection before it is queued", async () => {
|
||||
it("hands over the photos of the form that was saved, even when a draft is continued meanwhile", async () => {
|
||||
storeDraft("draft-session");
|
||||
let finishSaving: (inspection: never) => void = () => undefined;
|
||||
vi.mocked(createInspection).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishSaving = resolve as typeof finishSaving;
|
||||
}),
|
||||
);
|
||||
vi.mocked(handOverSessionPhotos).mockResolvedValue(0);
|
||||
renderPage();
|
||||
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
|
||||
const savedFormSession = shownSession();
|
||||
|
||||
await save();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
|
||||
await waitFor(() => expect(shownSession()).toBe("draft-session"));
|
||||
await act(async () => finishSaving({ id: "i-1", colonyId: "colony-1" } as never));
|
||||
|
||||
await waitFor(() => expect(handOverSessionPhotos).toHaveBeenCalled());
|
||||
expect(handOverSessionPhotos).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
sessionId: savedFormSession,
|
||||
inspectionId: "i-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends nothing after an online save without photos", async () => {
|
||||
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
|
||||
vi.mocked(handOverSessionPhotos).mockResolvedValue(0);
|
||||
renderPage();
|
||||
|
||||
await save();
|
||||
|
||||
await waitFor(() => expect(handOverSessionPhotos).toHaveBeenCalled());
|
||||
await settle();
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
|
||||
});
|
||||
|
||||
it("clears the draft of the saved inspection at once, also when its photos must wait", async () => {
|
||||
vi.mocked(listSessionPhotos).mockResolvedValue([PHOTO]);
|
||||
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
|
||||
// The hand-over stays noted; the next start or sync finishes it.
|
||||
vi.mocked(handOverSessionPhotos).mockRejectedValue(
|
||||
new Error("The database connection is closing."),
|
||||
);
|
||||
renderPage();
|
||||
expect(await screen.findByRole("img", { name: "Foto 1" })).toBeInTheDocument();
|
||||
await typeUntilDrafted();
|
||||
|
||||
await save();
|
||||
|
||||
// Never offered again: restoring it would save the inspection twice.
|
||||
expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith("Fotos werden später gesendet."));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues the inspection together with its photos", async () => {
|
||||
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(queueInspection).mockResolvedValue(undefined);
|
||||
vi.mocked(queueInspectionWithPhotos).mockResolvedValue(1);
|
||||
renderPage();
|
||||
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
|
||||
|
||||
await save();
|
||||
|
||||
await waitFor(() => expect(queueInspection).toHaveBeenCalled());
|
||||
const [, idempotencyKey] = vi.mocked(queueInspection).mock.calls[0];
|
||||
expect(attachSessionToQueuedInspection).toHaveBeenCalledWith(shownSession(), idempotencyKey);
|
||||
// A sync that sends the inspection right away already finds its photos.
|
||||
expect(vi.mocked(attachSessionToQueuedInspection).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(queueInspection).mock.invocationCallOrder[0],
|
||||
await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled());
|
||||
expect(queueInspectionWithPhotos).toHaveBeenCalledWith(
|
||||
shownSession(),
|
||||
expect.objectContaining({ colonyId: "colony-1" }),
|
||||
expect.any(String),
|
||||
"user-1",
|
||||
);
|
||||
expect(handOverSessionPhotos).not.toHaveBeenCalled();
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
"Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.",
|
||||
);
|
||||
expect(attachSessionToInspection).not.toHaveBeenCalled();
|
||||
expect(releaseSessionPhotos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives the photos back to the form when the inspection cannot be queued", async () => {
|
||||
it("keeps the photos with the form when the inspection cannot be queued", async () => {
|
||||
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(queueInspection).mockRejectedValue(new Error("QuotaExceededError"));
|
||||
vi.mocked(queueInspectionWithPhotos).mockRejectedValue(new Error("QuotaExceededError"));
|
||||
renderPage();
|
||||
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
|
||||
const formSession = shownSession();
|
||||
|
||||
await save();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Durchsicht konnte nicht gespeichert werden.",
|
||||
);
|
||||
expect(releaseSessionPhotos).toHaveBeenCalledWith(shownSession());
|
||||
expect(queueInspectionWithPhotos).toHaveBeenCalledWith(
|
||||
formSession,
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
"user-1",
|
||||
);
|
||||
expect(shownSession()).toBe(formSession);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("„Verwerfen“ of an earlier draft also deletes its photos", async () => {
|
||||
@@ -693,8 +835,14 @@ describe("NewInspectionPage", () => {
|
||||
expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("brings the photos of a restored draft back", async () => {
|
||||
it("brings the photos of a restored draft back and takes the fresh form's photos along", async () => {
|
||||
storeDraft("draft-session-0001");
|
||||
let finishMove: (moved: { moved: number; discarded: number }) => void = () => undefined;
|
||||
vi.mocked(moveDraftPhotos).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishMove = resolve;
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
|
||||
const freshSession = shownSession();
|
||||
@@ -703,9 +851,34 @@ describe("NewInspectionPage", () => {
|
||||
|
||||
await waitFor(() => expect(shownSession()).toBe("draft-session-0001"));
|
||||
expect(screen.getByDisplayValue("8")).toBeInTheDocument();
|
||||
// The fresh form's own photos give way to the restored entry, like its values.
|
||||
expect(discardSessionPhotos).toHaveBeenCalledWith(freshSession);
|
||||
expect(discardSessionPhotos).not.toHaveBeenCalledWith("draft-session-0001");
|
||||
// Photos taken before restoring join the restored entry - never deleted unasked.
|
||||
expect(moveDraftPhotos).toHaveBeenCalledWith(freshSession, "draft-session-0001");
|
||||
expect(discardSessionPhotos).not.toHaveBeenCalled();
|
||||
const readsBefore = vi.mocked(listSessionPhotos).mock.calls.length;
|
||||
|
||||
await act(async () => finishMove({ moved: 2, discarded: 0 }));
|
||||
|
||||
// Read again once they are there.
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(listSessionPhotos).mock.calls.length).toBeGreaterThan(readsBefore),
|
||||
);
|
||||
expect(shownSession()).toBe("draft-session-0001");
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says how many photos did not fit into the restored draft and were deleted", async () => {
|
||||
storeDraft("draft-session-0001");
|
||||
vi.mocked(moveDraftPhotos).mockResolvedValue({ moved: 1, discarded: 2 });
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
"2 Fotos wurden nicht übernommen und verworfen. Höchstens 5 Fotos je Durchsicht.",
|
||||
),
|
||||
);
|
||||
expect(discardSessionPhotos).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -17,17 +17,18 @@ import {
|
||||
undoTakeOver,
|
||||
type TakeoverField,
|
||||
} from "@/lib/inspections/takeover";
|
||||
import { useLastInspection } from "@/lib/inspections/use-last-inspection";
|
||||
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
|
||||
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
|
||||
import type { CreateInspectionInput } from "@/lib/inspections/types";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { queueInspection } from "@/lib/offline/db";
|
||||
import { photosNotTakenOver } from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY, useCaptureMutation } from "@/lib/offline/use-capture-mutation";
|
||||
import {
|
||||
attachSessionToInspection,
|
||||
attachSessionToQueuedInspection,
|
||||
PHOTOS_PER_INSPECTION_MAX,
|
||||
discardSessionPhotos,
|
||||
releaseSessionPhotos,
|
||||
handOverSessionPhotos,
|
||||
moveDraftPhotos,
|
||||
queueInspectionWithPhotos,
|
||||
} from "@/lib/photos/store";
|
||||
import { toCalendarDay, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { captureBackTarget } from "@/lib/forms/back-target";
|
||||
@@ -51,7 +52,7 @@ import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { PreviousValueHint } from "@/components/form/previous-value-hint";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import { PhotoCaptureField } from "@/components/photos/photo-capture-field";
|
||||
import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field";
|
||||
import {
|
||||
FEED_STATUS_OPTIONS,
|
||||
INSPECTION_OBSERVATIONS,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
const UNDO_TOAST_MS = 8000;
|
||||
const WEATHER_UPDATED = "Wetter aktualisiert";
|
||||
const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag";
|
||||
const PHOTOS_LATER = "Fotos werden später gesendet.";
|
||||
|
||||
const TAKEOVER_LABELS: Record<TakeoverField, string> = {
|
||||
queenSeen: "Königin gesehen",
|
||||
@@ -139,43 +141,8 @@ function useWeatherAutofill(values: InspectionFormValues, setValues: SetValues):
|
||||
return `${WEATHER_UPDATED} (${day}): ${summary}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline save: the form's photos are tied to the queue entry's key before
|
||||
* the entry exists, so a sync that sends the inspection right away already
|
||||
* finds them. If queueing fails, they belong to the open form again.
|
||||
*/
|
||||
async function queueInspectionWithPhotos(
|
||||
photoSessionId: string,
|
||||
input: CreateInspectionInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
await attachSessionToQueuedInspection(photoSessionId, idempotencyKey);
|
||||
try {
|
||||
await queueInspection(input, idempotencyKey, ownerUserId);
|
||||
} catch (error) {
|
||||
await releaseSessionPhotos(photoSessionId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Online save: the photos belong to the saved inspection and go up in the
|
||||
* background - inside a sync run, so a logout's cancelSync() stops them too.
|
||||
*/
|
||||
async function sendPhotosOf(
|
||||
photoSessionId: string,
|
||||
inspectionId: string,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
await attachSessionToInspection(photoSessionId, inspectionId);
|
||||
if (userId) {
|
||||
await syncPendingEntries(userId);
|
||||
}
|
||||
}
|
||||
|
||||
interface LastInspectionBarProps {
|
||||
last: Inspection;
|
||||
last: LastInspection;
|
||||
onTakeOverAll: () => void;
|
||||
}
|
||||
|
||||
@@ -247,12 +214,72 @@ function InspectionCaptureForm({
|
||||
if (key === "date") setDateError(null);
|
||||
}
|
||||
|
||||
/** How many photos the form's photo section shows (none while it is hidden). */
|
||||
function shownPhotoCount(photoSessionId: string): number {
|
||||
return (
|
||||
queryClient.getQueryData<unknown[]>([...PHOTO_SESSION_QUERY_KEY, photoSessionId])?.length ??
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Online save: the photos belong to the saved inspection and go up in the
|
||||
* background - inside a sync run, so a logout's cancelSync() stops them too.
|
||||
* If they cannot be tied to it right now, the hand-over stays noted for this
|
||||
* user: the next start or sync finishes it, and keeps the photos till then.
|
||||
*/
|
||||
async function handOverPhotos(photoSessionId: string, inspectionId: string): Promise<void> {
|
||||
// Inside the signed-in app there always is a user; nothing is sent without one.
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
let handedOver: number;
|
||||
try {
|
||||
handedOver = await handOverSessionPhotos({
|
||||
userId,
|
||||
sessionId: photoSessionId,
|
||||
inspectionId,
|
||||
});
|
||||
} catch {
|
||||
if (shownPhotoCount(photoSessionId) > 0) {
|
||||
toast.info(PHOTOS_LATER);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (handedOver === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// A sync already running may have passed these photos: it runs once more.
|
||||
await syncPendingEntries(userId, { afterChange: true });
|
||||
} catch {
|
||||
toast.error("Fotos konnten nicht gesendet werden.");
|
||||
}
|
||||
// The band's count, and the documents of the inspection (DocumentUploadCard).
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: ["documents", "INSPECTION", inspectionId] });
|
||||
}
|
||||
|
||||
// The photo session of the form that was submitted: "Weitermachen" while the
|
||||
// save runs swaps the form's session, and the photos of the saved entry must
|
||||
// not follow it.
|
||||
const submittedPhotoSession = useRef(values.photoSessionId);
|
||||
|
||||
const capture = useCaptureMutation({
|
||||
create: createInspection,
|
||||
queue: (input: CreateInspectionInput, idempotencyKey, ownerUserId) =>
|
||||
queueInspectionWithPhotos(values.photoSessionId, input, idempotencyKey, ownerUserId),
|
||||
// Queued together with its photos in one step: none is left waiting for
|
||||
// an entry that was never queued.
|
||||
queue: async (input: CreateInspectionInput, idempotencyKey, ownerUserId) => {
|
||||
await queueInspectionWithPhotos(
|
||||
submittedPhotoSession.current,
|
||||
input,
|
||||
idempotencyKey,
|
||||
ownerUserId,
|
||||
);
|
||||
},
|
||||
failureMessage: "Durchsicht konnte nicht gespeichert werden.",
|
||||
onSaved: (outcome, input) => {
|
||||
// At once: a draft of a saved inspection would be offered to save again.
|
||||
draft.clear();
|
||||
const savedOffline = outcome.via === "queued";
|
||||
if (outcome.via === "queued") {
|
||||
@@ -264,9 +291,7 @@ function InspectionCaptureForm({
|
||||
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
|
||||
});
|
||||
toast.success("Durchsicht gespeichert");
|
||||
void sendPhotosOf(values.photoSessionId, outcome.result.id, userId).catch(() =>
|
||||
toast.error("Fotos konnten nicht gesendet werden."),
|
||||
);
|
||||
void handOverPhotos(submittedPhotoSession.current, outcome.result.id);
|
||||
}
|
||||
const advanced = stand.round ? stand.markDone(input.colonyId) : null;
|
||||
if (advanced) {
|
||||
@@ -289,19 +314,35 @@ function InspectionCaptureForm({
|
||||
document.getElementById("inspection-date")?.focus();
|
||||
return;
|
||||
}
|
||||
submittedPhotoSession.current = values.photoSessionId;
|
||||
capture.submit(toInspectionInput(values));
|
||||
}
|
||||
|
||||
/**
|
||||
* Photos taken before „Weitermachen" join the restored entry, as far as the
|
||||
* photo limit allows. Those that do not fit are deleted, and the toast says
|
||||
* so - the form could not show them any more.
|
||||
*/
|
||||
async function takePhotosAlong(fromSessionId: string, toSessionId: string): Promise<void> {
|
||||
try {
|
||||
const { discarded } = await moveDraftPhotos(fromSessionId, toSessionId);
|
||||
if (discarded > 0) {
|
||||
toast.info(
|
||||
`${photosNotTakenOver(discarded)}. Höchstens ${PHOTOS_PER_INSPECTION_MAX} Fotos je Durchsicht.`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Fotos konnten nicht übernommen werden.");
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: PHOTO_SESSION_QUERY_KEY });
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
const freshSessionId = values.photoSessionId;
|
||||
// A draft from before photos existed has no session: the form keeps its own.
|
||||
const photoSessionId = restored.photoSessionId ?? values.photoSessionId;
|
||||
if (photoSessionId !== values.photoSessionId) {
|
||||
// Like the typed values, photos taken before restoring give way to the
|
||||
// restored entry. Unsaved photos left behind are purged after 3 days.
|
||||
void discardSessionPhotos(values.photoSessionId).catch(() => undefined);
|
||||
}
|
||||
const photoSessionId = restored.photoSessionId ?? freshSessionId;
|
||||
// A draft from before these fields existed lacks them.
|
||||
setValues({
|
||||
...initialInspectionValues(restored.colonyId),
|
||||
@@ -310,6 +351,9 @@ function InspectionCaptureForm({
|
||||
weatherAuto: restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""),
|
||||
});
|
||||
setDirty(true);
|
||||
if (photoSessionId !== freshSessionId) {
|
||||
void takePhotosAlong(freshSessionId, photoSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,7 +649,8 @@ function InspectionCaptureForm({
|
||||
title="Wetter"
|
||||
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
|
||||
>
|
||||
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:hidden">
|
||||
{/* Present while empty (only visually hidden), or its first message is not announced. */}
|
||||
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:sr-only">
|
||||
{weatherNotice}
|
||||
</p>
|
||||
<Field label="Wetter">
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import AppLayout from "./layout";
|
||||
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
|
||||
usePathname: () => "/inspections/new",
|
||||
}));
|
||||
|
||||
let sessionMode: "online" | "offline" = "online";
|
||||
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
@@ -23,20 +27,43 @@ vi.mock("@/lib/auth/auth-context", () => ({
|
||||
hasAvatar: false,
|
||||
},
|
||||
isLoading: false,
|
||||
sessionMode,
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/auth/logout-confirm-dialog", () => ({
|
||||
LogoutConfirmDialog: ({
|
||||
open,
|
||||
userId,
|
||||
onLoggedOut,
|
||||
}: {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
onLoggedOut: () => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="Abmelden?">
|
||||
{userId}
|
||||
<button type="button" onClick={onLoggedOut}>
|
||||
Abgemeldet
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null }));
|
||||
vi.mock("@/components/offline/offline-band", () => ({
|
||||
OfflineBand: ({
|
||||
userId,
|
||||
pageContentRef,
|
||||
syncEnabled,
|
||||
}: {
|
||||
userId: string;
|
||||
pageContentRef: { current: HTMLElement | null };
|
||||
syncEnabled?: boolean;
|
||||
}) => (
|
||||
<div data-testid="offline-band">
|
||||
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)}>
|
||||
{userId}
|
||||
<button type="button" onClick={() => pageContentRef.current?.focus()}>
|
||||
Fokus auf den Seiteninhalt
|
||||
@@ -45,7 +72,10 @@ vi.mock("@/components/offline/offline-band", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
|
||||
vi.mock("@/lib/photos/store", () => ({ purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0) }));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0),
|
||||
finishHandOvers: vi.fn().mockResolvedValue(0),
|
||||
}));
|
||||
|
||||
// A box with overflow auto/scroll/hidden is a scroll container. Any such
|
||||
// ancestor of the page content captures `position: sticky` (StickySubmit),
|
||||
@@ -59,6 +89,11 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
|
||||
}
|
||||
|
||||
describe("AppLayout", () => {
|
||||
beforeEach(() => {
|
||||
sessionMode = "online";
|
||||
pushMock.mockClear();
|
||||
});
|
||||
|
||||
it("shows the offline band for the signed-in user right below the header", () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
@@ -71,6 +106,41 @@ describe("AppLayout", () => {
|
||||
expect(band.previousElementSibling?.tagName).toBe("HEADER");
|
||||
});
|
||||
|
||||
it("lets the band send only in an online session", () => {
|
||||
const { unmount } = render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "true");
|
||||
unmount();
|
||||
|
||||
sessionMode = "offline";
|
||||
render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "false");
|
||||
});
|
||||
|
||||
it("asks through the logout dialog and moves to the login page once signed out", async () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog", { name: "Abmelden?" })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Konto-Menü" }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "Abmelden" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
expect(dialog).toHaveTextContent("u1");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abgemeldet" }));
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("gives the band the page content wrapper as a stable focus target", () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
@@ -100,6 +170,19 @@ describe("AppLayout", () => {
|
||||
expect(now).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("finishes the signed-in user's open photo hand-overs when the app starts", async () => {
|
||||
vi.mocked(finishHandOvers).mockClear();
|
||||
render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
|
||||
// Even offline: the photos then wait as entries of their own.
|
||||
await waitFor(() => expect(finishHandOvers).toHaveBeenCalledTimes(1));
|
||||
expect(finishHandOvers).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
it("does not wrap the page content in its own scroll container", () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Hexagon } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
|
||||
import { NotificationBell } from "@/components/notification-bell";
|
||||
import { OfflineBand } from "@/components/offline/offline-band";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
@@ -25,7 +26,8 @@ import { PageTransition } from "./page-transition";
|
||||
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, logout } = useAuth();
|
||||
const { user, isLoading, sessionMode } = useAuth();
|
||||
const [isLogoutOpen, setIsLogoutOpen] = useState(false);
|
||||
// Focus lands here when the offline queue sheet closes and nothing else is left.
|
||||
const pageContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -41,6 +43,16 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
void purgeStaleDraftPhotos(Date.now()).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// Online saves whose photos could not be tied to their inspection at once:
|
||||
// finished for the signed-in user, even offline - the photos then wait as
|
||||
// entries of their own. Every sync run of that user tries again too.
|
||||
const userId = user?.id;
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
void finishHandOvers(userId).catch(() => undefined);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
if (isLoading || !user) {
|
||||
return null;
|
||||
}
|
||||
@@ -69,7 +81,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
<QuickAddSheet user={user} variant="header" />
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full">
|
||||
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
|
||||
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -84,9 +96,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
void logout().then(() => router.push("/login"));
|
||||
}}
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
>
|
||||
Abmelden
|
||||
</DropdownMenuItem>
|
||||
@@ -94,7 +104,11 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<OfflineBand userId={user.id} pageContentRef={pageContentRef} />
|
||||
<OfflineBand
|
||||
userId={user.id}
|
||||
pageContentRef={pageContentRef}
|
||||
syncEnabled={sessionMode === "online"}
|
||||
/>
|
||||
<div
|
||||
ref={pageContentRef}
|
||||
tabIndex={-1}
|
||||
@@ -105,6 +119,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
<BottomNav user={user} />
|
||||
<QuickAddSheet user={user} variant="fab" />
|
||||
<LogoutConfirmDialog
|
||||
open={isLogoutOpen}
|
||||
onOpenChange={setIsLogoutOpen}
|
||||
userId={user.id}
|
||||
onLoggedOut={() => router.push("/login")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,11 @@ describe("nav-config", () => {
|
||||
expect(admin?.items.map((item) => item.href)).toEqual(["/settings/audit-log"]);
|
||||
});
|
||||
|
||||
it("shows the System entry only with system:read", () => {
|
||||
expect(hrefs([])).not.toContain("/settings/system");
|
||||
expect(hrefs(["system:read"])).toContain("/settings/system");
|
||||
});
|
||||
|
||||
it("hides items the user may not see", () => {
|
||||
expect(hrefs([])).not.toContain("/winter");
|
||||
expect(hrefs(["winter-checks:read"])).toContain("/winter");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
@@ -146,6 +147,12 @@ export const ADMIN_GROUP: NavGroup = {
|
||||
icon: History,
|
||||
permission: "audit:read",
|
||||
},
|
||||
{
|
||||
label: "System",
|
||||
href: "/settings/system",
|
||||
icon: Activity,
|
||||
permission: "system:read",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import {
|
||||
offlinePack,
|
||||
packColony,
|
||||
packTask,
|
||||
storedPack,
|
||||
} from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import OfflineApiaryPage from "./page";
|
||||
|
||||
let searchString = "id=apiary-1";
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(searchString),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
// No network: every server request fails the way it does in airplane mode.
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<OfflineApiaryPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OfflineApiaryPage", () => {
|
||||
beforeEach(async () => {
|
||||
searchString = "id=apiary-1";
|
||||
await deleteAllPacks();
|
||||
});
|
||||
|
||||
it("lists the pack's colonies with their light, linking to the offline colony view", async () => {
|
||||
const pack = offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
apiaryName: "Obstwiese",
|
||||
colonies: [packColony("colony-1", "A1"), packColony("colony-2", "A2", { status: "QUEENLESS" })],
|
||||
tasks: [packTask("t-1", "Zaun prüfen", { relatedType: "APIARY", relatedId: "apiary-1" })],
|
||||
});
|
||||
const db = await getDb();
|
||||
await db.put("offline-packs", storedPack(pack));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Obstwiese" })).toBeInTheDocument();
|
||||
const second = screen.getByRole("link", { name: /A2/ });
|
||||
expect(second).toHaveAttribute("href", "/offline/colony?id=colony-2");
|
||||
expect(within(second).getByText("Kritisch")).toBeInTheDocument();
|
||||
expect(second).toHaveTextContent(/Weisellos/);
|
||||
expect(screen.getByText("Zaun prüfen")).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Geladen vor/)).toBeInTheDocument();
|
||||
// The stand round works offline too: its colonies come from the pack.
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Runde starten" })).toHaveAttribute(
|
||||
"href",
|
||||
"/inspections/new?colonyId=colony-1&round=apiary-1",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("leads back to the apiary page once the network is back", async () => {
|
||||
const db = await getDb();
|
||||
await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1" })));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/apiaries/apiary-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains how to load an apiary that is not loaded", async () => {
|
||||
searchString = "id=apiary-unknown";
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Dieser Stand ist nicht offline geladen.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lade den Stand bei Netz mit „Für offline laden“.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
import { DetailHeader } from "@/components/detail-header";
|
||||
import { PackAgeNote, useOwnPack } from "@/components/offline/load-pack-button";
|
||||
import { ColonyOverviewLinks, StartRoundButton } from "../../apiaries/[id]/apiary-colonies-card";
|
||||
import {
|
||||
NotLoadedOffline,
|
||||
OFFLINE_HOME,
|
||||
OfflineLoading,
|
||||
OfflinePageFrame,
|
||||
OfflineReadError,
|
||||
OfflineSection,
|
||||
OpenTaskList,
|
||||
ReturnOnline,
|
||||
returnHrefFrom,
|
||||
} from "../offline-parts";
|
||||
|
||||
function offlineColonyHref(colonyId: string): string {
|
||||
return `/offline/colony?id=${encodeURIComponent(colonyId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The apiary as it was loaded for offline use - where a QR/NFC link to
|
||||
* /apiaries/:id lands without network. Read-only; the stand round starts
|
||||
* from here like online.
|
||||
*/
|
||||
export default function OfflineApiaryPage() {
|
||||
return (
|
||||
<Suspense fallback={<OfflineLoading what="Standdaten" />}>
|
||||
<OfflineApiary />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function OfflineApiary() {
|
||||
const searchParams = useSearchParams();
|
||||
const apiaryId = searchParams.get("id") ?? "";
|
||||
const returnHref = returnHrefFrom(searchParams, `/apiaries/${encodeURIComponent(apiaryId)}`);
|
||||
const userId = useCurrentUserId();
|
||||
const query = useOwnPack(apiaryId, userId);
|
||||
|
||||
if (query.isLoading) {
|
||||
return <OfflineLoading what="Standdaten" />;
|
||||
}
|
||||
if (query.isError) {
|
||||
return <OfflineReadError onRetry={() => void query.refetch()} />;
|
||||
}
|
||||
if (!query.data) {
|
||||
return <NotLoadedOffline title="Dieser Stand ist nicht offline geladen." returnHref={returnHref} />;
|
||||
}
|
||||
return (
|
||||
<OfflineApiaryView stored={query.data} readAt={query.dataUpdatedAt} returnHref={returnHref} />
|
||||
);
|
||||
}
|
||||
|
||||
interface OfflineApiaryViewProps {
|
||||
stored: StoredPack;
|
||||
/** When the pack was read from storage - the "now" of its age. */
|
||||
readAt: number;
|
||||
/** The page the user wanted, for when the network is back. */
|
||||
returnHref: string;
|
||||
}
|
||||
|
||||
function OfflineApiaryView({ stored, readAt, returnHref }: OfflineApiaryViewProps) {
|
||||
const { apiary, overview, tasks } = stored.pack;
|
||||
const apiaryTasks =
|
||||
tasks?.filter((task) => task.relatedType === "APIARY" && task.relatedId === apiary.id) ?? null;
|
||||
|
||||
return (
|
||||
<OfflinePageFrame>
|
||||
<DetailHeader
|
||||
icon={MapPin}
|
||||
title={apiary.name}
|
||||
subtitle={apiary.location ?? undefined}
|
||||
primaryAction={<StartRoundButton apiary={{ id: apiary.id, name: apiary.name }} />}
|
||||
backHref={OFFLINE_HOME}
|
||||
backLabel="Offline geladene Stände"
|
||||
/>
|
||||
<ReturnOnline href={returnHref} />
|
||||
<PackAgeNote stored={stored} now={readAt} />
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<OfflineSection title={`Völker (${overview.rows.length})`}>
|
||||
{overview.rows.length === 0 ? (
|
||||
<p className="text-muted-foreground">Keine Völker an diesem Stand.</p>
|
||||
) : (
|
||||
<ColonyOverviewLinks overview={overview} hrefFor={offlineColonyHref} />
|
||||
)}
|
||||
</OfflineSection>
|
||||
{apiaryTasks !== null ? (
|
||||
<OfflineSection title="Offene Aufgaben am Stand">
|
||||
<OpenTaskList tasks={apiaryTasks} />
|
||||
</OfflineSection>
|
||||
) : null}
|
||||
</div>
|
||||
</OfflinePageFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { getDb, type StoredPack } from "@/lib/offline/db";
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import {
|
||||
DAY_MS,
|
||||
MINUTE_MS,
|
||||
offlinePack,
|
||||
packColony,
|
||||
packInspection,
|
||||
packQueen,
|
||||
packTask,
|
||||
storedPack,
|
||||
} from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import OfflineColonyPage from "./page";
|
||||
|
||||
let searchString = "id=colony-1";
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(searchString),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useCurrentUserId: () => "user-1",
|
||||
useAuth: () => ({ hasPermission: () => true }),
|
||||
}));
|
||||
|
||||
function day(isoDay: string): string {
|
||||
return `${isoDay}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
function germanDay(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${dd}.${mm}.${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
const COLONY = packColony("colony-1", "A1", { currentQueenId: "queen-1", strength: 9 });
|
||||
|
||||
function fullPack() {
|
||||
return offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
apiaryName: "Obstwiese",
|
||||
colonies: [COLONY, packColony("colony-2", "A2")],
|
||||
queens: [packQueen("queen-1", "K-12", { hatchYear: 2025, breed: "Carnica" })],
|
||||
inspections: [
|
||||
packInspection("i-1", "colony-1", day("2026-10-01"), { colonyStrength: 9, queenSeen: true }),
|
||||
packInspection("i-2", "colony-1", day("2026-09-20")),
|
||||
packInspection("i-3", "colony-1", day("2026-09-10")),
|
||||
packInspection("i-4", "colony-1", day("2026-08-30")),
|
||||
packInspection("i-5", "colony-2", day("2026-09-25")),
|
||||
],
|
||||
tasks: [
|
||||
packTask("t-1", "Futter nachgeben", { relatedType: "COLONY", relatedId: "colony-1", dueDate: day("2026-10-15") }),
|
||||
packTask("t-2", "Anderes Volk", { relatedType: "COLONY", relatedId: "colony-2" }),
|
||||
packTask("t-3", "Zaun prüfen", { relatedType: "APIARY", relatedId: "apiary-1" }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function putPacks(...packs: StoredPack[]): Promise<void> {
|
||||
const db = await getDb();
|
||||
await Promise.all(packs.map((pack) => db.put("offline-packs", pack)));
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<OfflineColonyPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OfflineColonyPage", () => {
|
||||
beforeEach(async () => {
|
||||
searchString = "id=colony-1";
|
||||
await deleteAllPacks();
|
||||
});
|
||||
|
||||
it("renders colony, queen, last three inspections and open tasks from the pack", async () => {
|
||||
await putPacks(storedPack(fullPack()));
|
||||
renderPage();
|
||||
|
||||
const title = await screen.findByRole("heading", { level: 1, name: "A1" });
|
||||
const header = title.closest("header") as HTMLElement;
|
||||
expect(within(header).getByText(/Obstwiese/)).toBeInTheDocument();
|
||||
// The same traffic light as online: icon and word.
|
||||
expect(within(header).getByText("Gut")).toBeInTheDocument();
|
||||
expect(within(header).getByText("Aktiv")).toBeInTheDocument();
|
||||
const colony = screen.getByRole("region", { name: "Volk" });
|
||||
expect(within(colony).getByText("9 Waben")).toBeInTheDocument();
|
||||
|
||||
const queen = screen.getByRole("region", { name: "Königin" });
|
||||
expect(within(queen).getByText("K-12")).toBeInTheDocument();
|
||||
expect(within(queen).getByText(/Carnica/)).toBeInTheDocument();
|
||||
expect(within(queen).getByText(/2025/)).toBeInTheDocument();
|
||||
|
||||
const inspections = screen.getByRole("region", { name: "Letzte Durchsichten" });
|
||||
expect(within(inspections).getByText("01.10.2026")).toBeInTheDocument();
|
||||
expect(within(inspections).getByText("20.09.2026")).toBeInTheDocument();
|
||||
expect(within(inspections).getByText("10.09.2026")).toBeInTheDocument();
|
||||
expect(within(inspections).queryByText("30.08.2026")).not.toBeInTheDocument();
|
||||
expect(within(inspections).queryByText("25.09.2026")).not.toBeInTheDocument();
|
||||
|
||||
const tasks = screen.getByRole("region", { name: "Offene Aufgaben" });
|
||||
expect(within(tasks).getByText("Futter nachgeben")).toBeInTheDocument();
|
||||
expect(within(tasks).queryByText("Anderes Volk")).not.toBeInTheDocument();
|
||||
expect(within(tasks).queryByText("Zaun prüfen")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("button", { name: /Durchsicht erfassen/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/inspections/new?colonyId=colony-1",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Varroa-Messung/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/varroa/new?colonyId=colony-1",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Fütterung/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/feedings/new?colonyId=colony-1",
|
||||
);
|
||||
// Read-only: nothing here edits the pack's records.
|
||||
expect(screen.queryByRole("button", { name: /Bearbeiten/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves out sections the user may not read", async () => {
|
||||
await putPacks(storedPack({ ...fullPack(), queens: null, inspections: null, tasks: null }));
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("heading", { level: 1, name: "A1" });
|
||||
expect(screen.queryByRole("region", { name: "Königin" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "Letzte Durchsichten" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "Offene Aufgaben" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the not-loaded message for an unknown colony", async () => {
|
||||
searchString = "id=colony-unknown";
|
||||
await putPacks(
|
||||
storedPack(fullPack()),
|
||||
// Another account's pack never counts as loaded for this user.
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-9", colonies: [packColony("colony-unknown", "X1")] }),
|
||||
{ ownerUserId: "user-2" },
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Dieses Volk ist nicht offline geladen.")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Lade den Stand bei Netz mit „Für offline laden“."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { level: 1, name: "X1" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the pack age and the stale hint", async () => {
|
||||
const savedAt = Date.now() - 2 * DAY_MS;
|
||||
await putPacks(storedPack(fullPack(), { savedAt: new Date(savedAt).toISOString() }));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Geladen vor 2 Tagen")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`Stand-Daten von ${germanDay(savedAt)} – bei Netz neu laden`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leads back to the page the user wanted once the network is back", async () => {
|
||||
searchString = "id=colony-1&from=%2Fcolonies%2Fcolony-1%3Ftab%3Dvarroa";
|
||||
await putPacks(storedPack(fullPack()));
|
||||
renderPage();
|
||||
|
||||
const back = await screen.findByRole("button", { name: /Mit Netz öffnen/ });
|
||||
expect(back).toHaveAttribute("href", "/colonies/colony-1?tab=varroa");
|
||||
});
|
||||
|
||||
it("never follows a foreign return address - it leads to the colony's own page", async () => {
|
||||
searchString = "id=colony-1&from=https%3A%2F%2Fevil.example%2Fcolonies";
|
||||
await putPacks(storedPack(fullPack()));
|
||||
renderPage();
|
||||
|
||||
const back = await screen.findByRole("button", { name: /Mit Netz öffnen/ });
|
||||
expect(back).toHaveAttribute("href", "/colonies/colony-1");
|
||||
});
|
||||
|
||||
it("offers to try again while still offline", async () => {
|
||||
Object.defineProperty(window.navigator, "onLine", { configurable: true, get: () => false });
|
||||
try {
|
||||
await putPacks(storedPack(fullPack()));
|
||||
renderPage();
|
||||
|
||||
const retry = await screen.findByRole("button", { name: /Erneut versuchen/ });
|
||||
expect(retry).toHaveAttribute("href", "/colonies/colony-1");
|
||||
} finally {
|
||||
Reflect.deleteProperty(window.navigator, "onLine");
|
||||
}
|
||||
});
|
||||
|
||||
it("also offers the way back for a colony that is not loaded", async () => {
|
||||
searchString = "id=colony-unknown&from=%2Fcolonies%2Fcolony-unknown";
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Dieses Volk ist nicht offline geladen.");
|
||||
expect(screen.getByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/colonies/colony-unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a fresh pack's age without the stale hint", async () => {
|
||||
await putPacks(
|
||||
storedPack(fullPack(), { savedAt: new Date(Date.now() - 5 * MINUTE_MS).toISOString() }),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Geladen vor 5 Minuten")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/bei Netz neu laden/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Layers } from "lucide-react";
|
||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import {
|
||||
OFFLINE_PACKS_QUERY_KEY,
|
||||
findPackForColony,
|
||||
type PackColony,
|
||||
type PackInspection,
|
||||
type PackQueen,
|
||||
} from "@/lib/apiaries/offline-pack";
|
||||
import {
|
||||
COLONY_CONDITION_LABELS,
|
||||
COLONY_STATUS_LABELS,
|
||||
COLONY_TYPE_LABELS,
|
||||
} from "@/lib/colonies/labels";
|
||||
import { colonyStatusLight } from "@/lib/colonies/status-light";
|
||||
import type { ColonyCondition } from "@/lib/colonies/types";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
|
||||
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
|
||||
import { DetailHeader, DetailStatus } from "@/components/detail-header";
|
||||
import { PackAgeNote } from "@/components/offline/load-pack-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QUICK_ACTIONS } from "../../quick-actions";
|
||||
import {
|
||||
NotLoadedOffline,
|
||||
OfflineLoading,
|
||||
OfflinePageFrame,
|
||||
OfflineReadError,
|
||||
OfflineSection,
|
||||
OpenTaskList,
|
||||
ReturnOnline,
|
||||
calendarDayText,
|
||||
returnHrefFrom,
|
||||
} from "../offline-parts";
|
||||
|
||||
const LAST_INSPECTIONS = 3;
|
||||
const INSPECTION_FORM = "/inspections/new";
|
||||
// The capture forms that take a colony and open without network (app shell).
|
||||
const COLONY_CAPTURE_FORMS = ["/varroa/new", "/swarm-control/new", "/treatments/new", "/feedings/new"];
|
||||
|
||||
/**
|
||||
* A colony as it was loaded for offline use - where a QR/NFC link to
|
||||
* /colonies/:id lands without network. Read-only, with the way to the
|
||||
* capture forms.
|
||||
*/
|
||||
export default function OfflineColonyPage() {
|
||||
return (
|
||||
<Suspense fallback={<OfflineLoading what="Volksdaten" />}>
|
||||
<OfflineColony />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function OfflineColony() {
|
||||
const searchParams = useSearchParams();
|
||||
const colonyId = searchParams.get("id") ?? "";
|
||||
const returnHref = returnHrefFrom(searchParams, `/colonies/${encodeURIComponent(colonyId)}`);
|
||||
const userId = useCurrentUserId();
|
||||
const query = useQuery({
|
||||
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "colony", colonyId],
|
||||
queryFn: () => findPackForColony(colonyId, userId ?? ""),
|
||||
enabled: userId !== null && colonyId !== "",
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return <OfflineLoading what="Volksdaten" />;
|
||||
}
|
||||
if (query.isError) {
|
||||
return <OfflineReadError onRetry={() => void query.refetch()} />;
|
||||
}
|
||||
const colony = query.data?.pack.colonies.find((candidate) => candidate.id === colonyId);
|
||||
if (!query.data || !colony) {
|
||||
return <NotLoadedOffline title="Dieses Volk ist nicht offline geladen." returnHref={returnHref} />;
|
||||
}
|
||||
return (
|
||||
<OfflineColonyView
|
||||
stored={query.data}
|
||||
colony={colony}
|
||||
readAt={query.dataUpdatedAt}
|
||||
returnHref={returnHref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function captureHref(form: string, colonyId: string): string {
|
||||
return `${form}?colonyId=${encodeURIComponent(colonyId)}`;
|
||||
}
|
||||
|
||||
interface OfflineColonyViewProps {
|
||||
stored: StoredPack;
|
||||
colony: PackColony;
|
||||
/** When the pack was read from storage - the "now" of its age. */
|
||||
readAt: number;
|
||||
/** The page the user wanted, for when the network is back. */
|
||||
returnHref: string;
|
||||
}
|
||||
|
||||
function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColonyViewProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const { pack } = stored;
|
||||
const row = pack.overview.rows.find((candidate) => candidate.id === colony.id);
|
||||
const light = row
|
||||
? colonyStatusLight(row, {
|
||||
today: todayLocalIsoDate(),
|
||||
inspectionsVisible: pack.overview.inspectionsVisible,
|
||||
})
|
||||
: null;
|
||||
const inspection = QUICK_ACTIONS.find((action) => action.href === INSPECTION_FORM);
|
||||
const queen = pack.queens?.find((candidate) => candidate.id === colony.currentQueenId) ?? null;
|
||||
const tasks =
|
||||
pack.tasks?.filter((task) => task.relatedType === "COLONY" && task.relatedId === colony.id) ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<OfflinePageFrame>
|
||||
<DetailHeader
|
||||
icon={Layers}
|
||||
title={colony.code}
|
||||
subtitle={`${COLONY_TYPE_LABELS[colony.colonyType]} · ${pack.apiary.name}`}
|
||||
status={
|
||||
<>
|
||||
{light ? <StatusLightBadge light={light.light} /> : null}
|
||||
<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />
|
||||
</>
|
||||
}
|
||||
primaryAction={
|
||||
inspection && hasPermission(inspection.permission) ? (
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={<Link href={captureHref(INSPECTION_FORM, colony.id)} />}
|
||||
>
|
||||
<inspection.icon aria-hidden />
|
||||
Durchsicht erfassen
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
backHref={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
|
||||
backLabel="Zum Stand"
|
||||
/>
|
||||
<ReturnOnline href={returnHref} />
|
||||
<PackAgeNote stored={stored} now={readAt} />
|
||||
{light && light.reasons.length > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{light.reasons.join(" · ")}</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<ColonyFacts colony={colony} />
|
||||
{pack.queens !== null ? <QueenFacts queen={queen} /> : null}
|
||||
{pack.inspections !== null ? (
|
||||
<LastInspections inspections={pack.inspections} colonyId={colony.id} />
|
||||
) : null}
|
||||
{tasks !== null ? (
|
||||
<OfflineSection title="Offene Aufgaben">
|
||||
<OpenTaskList tasks={tasks} />
|
||||
</OfflineSection>
|
||||
) : null}
|
||||
<MoreCaptures colonyId={colony.id} hasPermission={hasPermission} />
|
||||
</div>
|
||||
</OfflinePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function conditionText(condition: ColonyCondition | null): string {
|
||||
return condition ? COLONY_CONDITION_LABELS[condition] : "–";
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-right">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColonyFacts({ colony }: { colony: PackColony }) {
|
||||
return (
|
||||
<OfflineSection title="Volk">
|
||||
<dl className="flex flex-col gap-1.5">
|
||||
<Fact label="Stärke" value={colony.strength !== null ? `${colony.strength} Waben` : "–"} />
|
||||
<Fact label="Brut" value={conditionText(colony.broodStatus)} />
|
||||
<Fact label="Futter" value={conditionText(colony.feedStatus)} />
|
||||
<Fact label="Gesundheit" value={conditionText(colony.healthStatus)} />
|
||||
<Fact label="Beute" value={colony.hiveType ?? "–"} />
|
||||
</dl>
|
||||
{colony.notes ? <p className="whitespace-pre-line">{colony.notes}</p> : null}
|
||||
</OfflineSection>
|
||||
);
|
||||
}
|
||||
|
||||
function QueenFacts({ queen }: { queen: PackQueen | null }) {
|
||||
if (!queen) {
|
||||
return (
|
||||
<OfflineSection title="Königin">
|
||||
<p className="text-muted-foreground">Keine Königin erfasst.</p>
|
||||
</OfflineSection>
|
||||
);
|
||||
}
|
||||
const marking = [queen.markingColor, queen.markingNumber].filter(Boolean).join(" ");
|
||||
return (
|
||||
<OfflineSection title="Königin">
|
||||
<p className="font-medium">{queen.code}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{[
|
||||
queen.hatchYear !== null ? `Jahrgang ${queen.hatchYear}` : null,
|
||||
queen.breed,
|
||||
marking ? `Zeichen ${marking}` : null,
|
||||
QUEEN_STATUS_LABELS[queen.status],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</OfflineSection>
|
||||
);
|
||||
}
|
||||
|
||||
function inspectionSummary(inspection: PackInspection): string {
|
||||
const parts = [
|
||||
inspection.colonyStrength !== null ? `${inspection.colonyStrength} Waben` : null,
|
||||
inspection.broodFrames !== null ? `${inspection.broodFrames} Brutwaben` : null,
|
||||
inspection.queenSeen !== null
|
||||
? `Königin ${inspection.queenSeen ? "gesehen" : "nicht gesehen"}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(" · ") : "Ohne Angaben zu Stärke und Königin";
|
||||
}
|
||||
|
||||
function LastInspections({
|
||||
inspections,
|
||||
colonyId,
|
||||
}: {
|
||||
inspections: readonly PackInspection[];
|
||||
colonyId: string;
|
||||
}) {
|
||||
const latest = inspections
|
||||
.filter((inspection) => inspection.colonyId === colonyId)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, LAST_INSPECTIONS);
|
||||
return (
|
||||
<OfflineSection title="Letzte Durchsichten">
|
||||
{latest.length === 0 ? <p className="text-muted-foreground">Noch keine Durchsicht.</p> : null}
|
||||
{latest.map((inspection) => (
|
||||
<div key={inspection.id} className="rounded-xl border border-border/60 px-3 py-2">
|
||||
<p className="font-medium">{calendarDayText(inspection.date)}</p>
|
||||
<p className="text-muted-foreground">{inspectionSummary(inspection)}</p>
|
||||
{inspection.notes ? <p className="whitespace-pre-line">{inspection.notes}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</OfflineSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreCapturesProps {
|
||||
colonyId: string;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
}
|
||||
|
||||
function MoreCaptures({ colonyId, hasPermission }: MoreCapturesProps) {
|
||||
const actions = QUICK_ACTIONS.filter(
|
||||
(action) => COLONY_CAPTURE_FORMS.includes(action.href) && hasPermission(action.permission),
|
||||
);
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<OfflineSection title="Weitere Erfassung">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.href}
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
nativeButton={false}
|
||||
render={<Link href={captureHref(action.href, colonyId)} />}
|
||||
>
|
||||
<action.icon aria-hidden />
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</OfflineSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useId } from "react";
|
||||
import Link from "next/link";
|
||||
import { CloudOff, RotateCw, Wifi } from "lucide-react";
|
||||
import type { PackTask } from "@/lib/apiaries/offline-pack";
|
||||
import { toCalendarDay } from "@/lib/dates";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { safeReturnPath } from "@/lib/offline/return-path";
|
||||
import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
// Shared by the offline pages (/offline/apiary, /offline/colony): read-only
|
||||
// views of an offline pack, opened by the service worker without network.
|
||||
|
||||
export const OFFLINE_HOME = "/offline";
|
||||
|
||||
/** A calendar-day timestamp of the API ("2026-10-01T00:00:00.000Z") as "01.10.2026". */
|
||||
export function calendarDayText(isoTimestamp: string): string {
|
||||
const [year, month, day] = toCalendarDay(isoTimestamp).split("-");
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function OfflinePageFrame({ children }: { children: ReactNode }) {
|
||||
return <main className="flex flex-col gap-4 p-4 sm:p-6">{children}</main>;
|
||||
}
|
||||
|
||||
export function OfflineLoading({ what }: { what: string }) {
|
||||
return (
|
||||
<OfflinePageFrame>
|
||||
<LoadingState what={what} />
|
||||
</OfflinePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function OfflineReadError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<OfflinePageFrame>
|
||||
<ErrorState what="Offline-Daten" onRetry={onRetry} />
|
||||
</OfflinePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where "Mit Netz öffnen" leads: the page the user wanted (`from`, from the
|
||||
* service worker - only a path of this app), else `fallbackHref`.
|
||||
*/
|
||||
export function returnHrefFrom(searchParams: URLSearchParams, fallbackHref: string): string {
|
||||
return safeReturnPath(searchParams.get("from")) ?? fallbackHref;
|
||||
}
|
||||
|
||||
/**
|
||||
* The way back out of the offline view: once the network is back, to the
|
||||
* page the user wanted; until then a new attempt. A full page load, so the
|
||||
* service worker decides again.
|
||||
*/
|
||||
export function ReturnOnline({ href }: { href: string }) {
|
||||
const isOnline = useConnectivity() === "online";
|
||||
const Icon = isOnline ? Wifi : CloudOff;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl bg-muted/60 p-3 text-sm"
|
||||
>
|
||||
<p className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0" aria-hidden />
|
||||
{isOnline ? "Netz ist wieder da." : "Kein Netz – du siehst die gespeicherten Daten."}
|
||||
</p>
|
||||
<Button size="touch" variant="outline" nativeButton={false} render={<a href={href} />}>
|
||||
<RotateCw aria-hidden />
|
||||
{isOnline ? "Mit Netz öffnen" : "Erneut versuchen"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NotLoadedOfflineProps {
|
||||
title: string;
|
||||
/** The online page of the record, for when the network is back. */
|
||||
returnHref: string;
|
||||
}
|
||||
|
||||
/** What to do when the deep-linked record was never loaded for offline use. */
|
||||
export function NotLoadedOffline({ title, returnHref }: NotLoadedOfflineProps) {
|
||||
return (
|
||||
<OfflinePageFrame>
|
||||
<ReturnOnline href={returnHref} />
|
||||
<EmptyState
|
||||
icon={CloudOff}
|
||||
title={title}
|
||||
description="Lade den Stand bei Netz mit „Für offline laden“."
|
||||
action={
|
||||
<Button size="touch" variant="outline" nativeButton={false} render={<Link href={OFFLINE_HOME} />}>
|
||||
Offline geladene Stände
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OfflinePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card that is a named region, so it can be found by its title. */
|
||||
export function OfflineSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
const headingId = useId();
|
||||
return (
|
||||
<Card role="region" aria-labelledby={headingId}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 id={headingId}>{title}</h2>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenTaskList({ tasks }: { tasks: readonly PackTask[] }) {
|
||||
if (tasks.length === 0) {
|
||||
return <p className="text-muted-foreground">Keine offenen Aufgaben.</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tasks.map((task) => (
|
||||
<li key={task.id} className="rounded-xl border border-border/60 px-3 py-2">
|
||||
<p className="font-medium">{task.title}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{[
|
||||
task.dueDate ? `fällig ${calendarDayText(task.dueDate)}` : null,
|
||||
`Priorität ${TASK_PRIORITY_LABELS[task.priority]}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import OfflinePage from "./page";
|
||||
|
||||
let searchString = "";
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(searchString),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<OfflinePage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OfflinePage", () => {
|
||||
beforeEach(async () => {
|
||||
searchString = "";
|
||||
await deleteAllPacks();
|
||||
});
|
||||
|
||||
it("lists the apiaries loaded for offline use, by name", async () => {
|
||||
const db = await getDb();
|
||||
await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-2", apiaryName: "Waldrand" })));
|
||||
await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1", apiaryName: "Obstwiese" })));
|
||||
await db.put(
|
||||
"offline-packs",
|
||||
storedPack(offlinePack({ apiaryId: "apiary-3", apiaryName: "Fremd" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const links = await screen.findAllByRole("link", { name: /Obstwiese|Waldrand/ });
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/offline/apiary?id=apiary-1",
|
||||
"/offline/apiary?id=apiary-2",
|
||||
]);
|
||||
expect(screen.queryByText("Fremd")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains how to prepare for offline use when nothing is loaded", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Noch kein Stand offline geladen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lade einen Stand bei Netz mit „Für offline laden“.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leads back to the page the user wanted once the network is back", async () => {
|
||||
searchString = "from=%2Fstatistics%3Fyear%3D2026";
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/statistics?year=2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no way back to a foreign address", async () => {
|
||||
searchString = "from=%2F%2Fevil.example";
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Noch kein Stand offline geladen");
|
||||
expect(screen.queryByRole("button", { name: /Mit Netz öffnen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, CloudOff, LayoutDashboard } from "lucide-react";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { safeReturnPath } from "@/lib/offline/return-path";
|
||||
import { OFFLINE_PACKS_QUERY_KEY, listPacks, packAgeText } from "@/lib/apiaries/offline-pack";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ReturnOnline } from "./offline-parts";
|
||||
|
||||
/** The page the service worker could not open (`from`), once the network is back. */
|
||||
function ReturnToWantedPage() {
|
||||
const href = safeReturnPath(useSearchParams().get("from"));
|
||||
return href ? <ReturnOnline href={href} /> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the service worker sends a page it cannot open without network:
|
||||
* the apiaries loaded for offline use. Static - the HTML is cached as part of
|
||||
* the app shell and reads this device's storage only.
|
||||
*/
|
||||
export default function OfflinePage() {
|
||||
const userId = useCurrentUserId();
|
||||
const query = useQuery({
|
||||
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "list"],
|
||||
queryFn: async () =>
|
||||
(await listPacks(userId ?? "")).sort((a, b) =>
|
||||
a.pack.apiary.name.localeCompare(b.pack.apiary.name, "de"),
|
||||
),
|
||||
enabled: userId !== null,
|
||||
networkMode: "always",
|
||||
});
|
||||
// Ages are told as of reading the packs (refreshed on every visit).
|
||||
const now = query.dataUpdatedAt;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={CloudOff}
|
||||
title="Offline"
|
||||
description="Ohne Netz zeigt ApiCore die Stände, die du für offline geladen hast."
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<ReturnToWantedPage />
|
||||
</Suspense>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2>Offline geladene Stände</h2>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{query.isLoading ? <LoadingState what="Stände" /> : null}
|
||||
{query.isError ? (
|
||||
<ErrorState what="Stände" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
|
||||
) : null}
|
||||
{query.data?.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CloudOff}
|
||||
title="Noch kein Stand offline geladen"
|
||||
description="Lade einen Stand bei Netz mit „Für offline laden“."
|
||||
/>
|
||||
) : null}
|
||||
{query.data?.map((stored) => (
|
||||
<Link
|
||||
key={stored.apiaryId}
|
||||
href={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="font-medium">{stored.pack.apiary.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Geladen {packAgeText(stored.savedAt, now)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<Button size="touch" variant="outline" nativeButton={false} render={<Link href="/dashboard" />}>
|
||||
<LayoutDashboard aria-hidden />
|
||||
Zur Übersicht
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { 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 { deactivateAccount } from "@/lib/profile/api";
|
||||
import { DangerZoneSection } from "./danger-zone-section";
|
||||
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/profile/api", () => ({ deactivateAccount: vi.fn() }));
|
||||
|
||||
const logoutMock = vi.fn();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ logout: logoutMock }) }));
|
||||
|
||||
function renderZone() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<DangerZoneSection />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmDeactivation() {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Konto löschen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await userEvent.type(within(dialog).getByLabelText("Passwort"), "secret-password");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Konto löschen" }));
|
||||
}
|
||||
|
||||
describe("DangerZoneSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logoutMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("signs out and deletes the unsent entries of a deactivated account", async () => {
|
||||
vi.mocked(deactivateAccount).mockResolvedValue(undefined);
|
||||
renderZone();
|
||||
|
||||
await confirmDeactivation();
|
||||
|
||||
// Nobody can ever send them: they must not wait for the next account.
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("stays signed in and keeps the entries when the account could not be deactivated", async () => {
|
||||
vi.mocked(deactivateAccount).mockRejectedValue(new Error("wrong password"));
|
||||
renderZone();
|
||||
|
||||
await confirmDeactivation();
|
||||
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,8 @@ function DeactivateAccountDialog() {
|
||||
mutationFn: () => deactivateAccount(currentPassword),
|
||||
onSuccess: async () => {
|
||||
toast.success("Konto deaktiviert");
|
||||
await logout();
|
||||
// The account is gone: its unsent entries can never be sent by anyone.
|
||||
await logout({ discardQueue: true });
|
||||
router.push("/login");
|
||||
},
|
||||
onError: () => setError("Passwort ist falsch oder das Konto kann nicht deaktiviert werden."),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { WebhookDeliveriesDialog } from "./webhook-deliveries-dialog";
|
||||
import { fetchWebhookDeliveries } from "@/lib/webhooks/api";
|
||||
|
||||
vi.mock("@/lib/webhooks/api", () => ({ fetchWebhookDeliveries: vi.fn() }));
|
||||
|
||||
function renderDialog() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WebhookDeliveriesDialog subscriptionId="s1" url="https://hooks.example.com/x" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("WebhookDeliveriesDialog", () => {
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("shows the delivery log of a webhook", async () => {
|
||||
vi.mocked(fetchWebhookDeliveries).mockResolvedValue([
|
||||
{
|
||||
id: "d1",
|
||||
event: "harvest.created",
|
||||
status: "DELIVERED",
|
||||
attempts: 1,
|
||||
lastStatusCode: 200,
|
||||
lastError: null,
|
||||
createdAt: "2026-10-13T08:00:00.000Z",
|
||||
deliveredAt: "2026-10-13T08:00:01.000Z",
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
event: "task.created",
|
||||
status: "FAILED",
|
||||
attempts: 8,
|
||||
lastStatusCode: 500,
|
||||
lastError: "Zeitüberschreitung",
|
||||
createdAt: "2026-10-12T08:00:00.000Z",
|
||||
deliveredAt: null,
|
||||
},
|
||||
{
|
||||
id: "d3",
|
||||
event: "task.completed",
|
||||
status: "PENDING",
|
||||
attempts: 0,
|
||||
lastStatusCode: null,
|
||||
lastError: null,
|
||||
createdAt: "2026-10-12T07:00:00.000Z",
|
||||
deliveredAt: null,
|
||||
},
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
expect(fetchWebhookDeliveries).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Zustellprotokoll" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Zustellprotokoll" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("Ernte erfasst")).toBeInTheDocument();
|
||||
expect(screen.getByText("Zugestellt")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Offen")).toBeInTheDocument();
|
||||
expect(screen.getByText("HTTP 200")).toBeInTheDocument();
|
||||
expect(screen.getByText("HTTP 500")).toBeInTheDocument();
|
||||
expect(screen.getByText("8 Versuche")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 Versuch")).toBeInTheDocument();
|
||||
expect(fetchWebhookDeliveries).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
|
||||
it("says so when nothing was delivered yet", async () => {
|
||||
vi.mocked(fetchWebhookDeliveries).mockResolvedValue([]);
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Zustellprotokoll" }));
|
||||
|
||||
expect(await screen.findByText("Noch keine Zustellungen.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CircleCheck, CircleX, Clock } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchWebhookDeliveries } from "@/lib/webhooks/api";
|
||||
import { WEBHOOK_EVENT_LABELS } from "@/lib/webhooks/labels";
|
||||
import { WEBHOOK_DELIVERY_STATUS_LABELS, attemptsLabel } from "@/lib/system/labels";
|
||||
import type { WebhookDeliveryStatus, WebhookEvent } from "@/lib/webhooks/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
|
||||
const STATUS_ICONS: Record<WebhookDeliveryStatus, typeof Clock> = {
|
||||
PENDING: Clock,
|
||||
PROCESSING: Clock,
|
||||
DELIVERED: CircleCheck,
|
||||
FAILED: CircleX,
|
||||
};
|
||||
|
||||
function DeliveryLog({ subscriptionId }: { subscriptionId: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ["webhook-deliveries", subscriptionId],
|
||||
queryFn: () => fetchWebhookDeliveries(subscriptionId),
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
query={query}
|
||||
what="Zustellungen"
|
||||
empty={<p className="text-sm text-muted-foreground">Noch keine Zustellungen.</p>}
|
||||
>
|
||||
{(deliveries) => (
|
||||
<ul className="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
|
||||
{deliveries.map((delivery) => {
|
||||
const Icon = STATUS_ICONS[delivery.status];
|
||||
return (
|
||||
<li
|
||||
key={delivery.id}
|
||||
className="flex flex-col gap-1 rounded-xl border border-border/60 bg-card p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={delivery.status === "FAILED" ? "destructive" : "secondary"}>
|
||||
<Icon aria-hidden />
|
||||
{WEBHOOK_DELIVERY_STATUS_LABELS[delivery.status]}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
{WEBHOOK_EVENT_LABELS[delivery.event as WebhookEvent] ?? delivery.event}
|
||||
</span>
|
||||
</div>
|
||||
<p className="flex flex-wrap gap-x-3 text-sm text-muted-foreground">
|
||||
<span>{new Date(delivery.createdAt).toLocaleString("de-DE")}</span>
|
||||
<span>{attemptsLabel(delivery.attempts)}</span>
|
||||
{delivery.lastStatusCode ? <span>HTTP {delivery.lastStatusCode}</span> : null}
|
||||
</p>
|
||||
{delivery.lastError ? (
|
||||
<p className="break-words text-sm text-destructive">{delivery.lastError}</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</QueryState>
|
||||
);
|
||||
}
|
||||
|
||||
/** The newest deliveries of one webhook - loaded only once the dialog opens. */
|
||||
export function WebhookDeliveriesDialog({
|
||||
subscriptionId,
|
||||
url,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
url: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button variant="outline" size="touch">Zustellprotokoll</Button>} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Zustellprotokoll</DialogTitle>
|
||||
<DialogDescription className="break-all">{url}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open ? <DeliveryLog subscriptionId={subscriptionId} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { CreateWebhookDialog } from "./create-webhook-dialog";
|
||||
import { EditWebhookDialog } from "./edit-webhook-dialog";
|
||||
import { WebhookDeliveriesDialog } from "./webhook-deliveries-dialog";
|
||||
|
||||
/** One card per connection - events in words, not technical keys. */
|
||||
export function WebhooksTab() {
|
||||
@@ -77,6 +78,7 @@ export function WebhooksTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<WebhookDeliveriesDialog subscriptionId={webhook.id} url={webhook.url} />
|
||||
<EditWebhookDialog webhook={webhook} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
|
||||
@@ -43,6 +43,12 @@ function schedule(overrides: Partial<BackupScheduleStatus> = {}): BackupSchedule
|
||||
lastBackupSizeBytes: 10 * MB,
|
||||
lastBackupExceedsImportLimit: false,
|
||||
envOverrides: { storagePath: true, cronSchedule: true, retentionCount: true },
|
||||
lastJobRun: {
|
||||
status: "SUCCEEDED",
|
||||
startedAt: "2026-10-13T01:00:02.000Z",
|
||||
finishedAt: "2026-10-13T01:00:09.000Z",
|
||||
error: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -93,6 +99,61 @@ describe("BackupPage", () => {
|
||||
expect(screen.queryByText(LIMIT_WARNING)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows that the schedule is checked hourly and when the job last ran", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
|
||||
|
||||
renderWithClient(<BackupPage />);
|
||||
|
||||
const lastRun = new Date("2026-10-13T01:00:02.000Z").toLocaleString("de-DE");
|
||||
expect(
|
||||
await screen.findByText(`Wird stündlich geprüft · letzter Lauf ${lastRun}`),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Fehlgeschlagen/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows why the last job run failed", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(
|
||||
schedule({
|
||||
lastJobRun: {
|
||||
status: "FAILED",
|
||||
startedAt: "2026-10-13T01:00:02.000Z",
|
||||
finishedAt: "2026-10-13T01:00:05.000Z",
|
||||
error: "EACCES: permission denied",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithClient(<BackupPage />);
|
||||
|
||||
const lastRun = new Date("2026-10-13T01:00:02.000Z").toLocaleString("de-DE");
|
||||
expect(
|
||||
await screen.findByText(
|
||||
`Wird stündlich geprüft · letzter Lauf ${lastRun} · Fehlgeschlagen: EACCES: permission denied`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so while the job has not run yet", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule({ lastJobRun: null }));
|
||||
|
||||
renderWithClient(<BackupPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Wird stündlich geprüft · noch kein Lauf"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the newest backup file as the last backup, not as the last run", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
|
||||
|
||||
renderWithClient(<BackupPage />);
|
||||
|
||||
expect(await screen.findByText("Letzte Sicherung")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date("2026-10-12T03:00:00.000Z").toLocaleString("de-DE")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns after a manual export that is larger than the import limit, but still downloads it", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule({ importLimitBytes: 10 }));
|
||||
vi.mocked(exportBackup).mockResolvedValue({
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/lib/backup/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
|
||||
import { BackupJobRunStatus, BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
|
||||
import { unsealedImportWarning } from "@/lib/backup/import-summary";
|
||||
import { importCsv } from "@/lib/import/api";
|
||||
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
|
||||
@@ -224,11 +224,25 @@ function BackupSection() {
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) {
|
||||
return "Noch nicht gelaufen";
|
||||
return "Noch keine Sicherung";
|
||||
}
|
||||
return new Date(value).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
// The schedule is checked by an hourly CronJob, not by a timer in the API:
|
||||
// what that job did last is what tells whether backups still happen.
|
||||
function jobRunSummary(run: BackupJobRunStatus | null): string {
|
||||
const checked = "Wird stündlich geprüft";
|
||||
if (!run) {
|
||||
return `${checked} · noch kein Lauf`;
|
||||
}
|
||||
const lastRun = `${checked} · letzter Lauf ${formatDateTime(run.startedAt)}`;
|
||||
if (run.status === "FAILED") {
|
||||
return run.error ? `${lastRun} · Fehlgeschlagen: ${run.error}` : `${lastRun} · Fehlgeschlagen`;
|
||||
}
|
||||
return run.status === "RUNNING" ? `${lastRun} · läuft gerade` : lastRun;
|
||||
}
|
||||
|
||||
// Mounted only once `settings` has arrived, so its useState initial values
|
||||
// already reflect the fetched data - matches the pattern already used for
|
||||
// WebsiteSettingsForm in social-media-tab.tsx.
|
||||
@@ -399,10 +413,21 @@ function ScheduleSection() {
|
||||
<dd>{status.cronSchedule}</dd>
|
||||
<dt className="text-muted-foreground">Aufbewahrung</dt>
|
||||
<dd>{status.retentionCount} neueste Backups</dd>
|
||||
<dt className="text-muted-foreground">Letzter Lauf</dt>
|
||||
<dt className="text-muted-foreground">Letzte Sicherung</dt>
|
||||
<dd>{formatDateTime(status.lastRunAt)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
{status ? (
|
||||
<p
|
||||
className={
|
||||
status.lastJobRun?.status === "FAILED"
|
||||
? "text-destructive"
|
||||
: "text-xs text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{jobRunSummary(status.lastJobRun)}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -31,6 +31,18 @@ describe("SettingsLayout", () => {
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a user with only system:read into the settings area", () => {
|
||||
mockUser = { permissions: ["system:read"] };
|
||||
|
||||
render(
|
||||
<SettingsLayout>
|
||||
<p>Einstellungen-Inhalt</p>
|
||||
</SettingsLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to the dashboard when the user lacks every settings-area permission", () => {
|
||||
mockUser = { permissions: ["colonies:read"] };
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const SETTINGS_AREA_PERMISSIONS = [
|
||||
"backup:configure",
|
||||
"calendar:configure",
|
||||
"api-keys:manage",
|
||||
"system:read",
|
||||
];
|
||||
|
||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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 { toast } from "sonner";
|
||||
import SystemPage from "./page";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
fetchFailedDomainEvents,
|
||||
fetchFailedWebhookDeliveries,
|
||||
fetchSystemOverview,
|
||||
retryDomainEvent,
|
||||
retryWebhookDelivery,
|
||||
} from "@/lib/system/api";
|
||||
import { JOB_NAMES, type SystemOverview } from "@/lib/system/types";
|
||||
|
||||
vi.mock("@/lib/system/api", () => ({
|
||||
fetchSystemOverview: vi.fn(),
|
||||
fetchFailedDomainEvents: vi.fn(),
|
||||
retryDomainEvent: vi.fn(),
|
||||
fetchFailedWebhookDeliveries: vi.fn(),
|
||||
retryWebhookDelivery: vi.fn(),
|
||||
}));
|
||||
let permissions: string[] = [];
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ user: { permissions } }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
function overview(overrides: Partial<SystemOverview["domainEvents"]> = {}): SystemOverview {
|
||||
return {
|
||||
jobs: JOB_NAMES.map((job) => ({
|
||||
job,
|
||||
lastRun:
|
||||
job === "cleanup"
|
||||
? {
|
||||
id: "r1",
|
||||
job,
|
||||
status: "FAILED" as const,
|
||||
startedAt: "2026-10-13T04:00:00.000Z",
|
||||
finishedAt: "2026-10-13T04:00:05.000Z",
|
||||
error: "Timeout",
|
||||
result: null,
|
||||
}
|
||||
: job === "daily-evaluation"
|
||||
? {
|
||||
id: "r2",
|
||||
job,
|
||||
status: "SUCCEEDED" as const,
|
||||
startedAt: "2026-10-13T04:30:00.000Z",
|
||||
finishedAt: "2026-10-13T04:30:05.000Z",
|
||||
error: null,
|
||||
result: { colonies: 4 },
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
domainEvents: {
|
||||
pending: 3,
|
||||
processing: 0,
|
||||
failed: 1,
|
||||
oldestPendingAgeSeconds: 60,
|
||||
...overrides,
|
||||
},
|
||||
webhookDeliveries: { pending: 0, failed: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
const failedEvent = {
|
||||
id: "ev1",
|
||||
type: "InspectionRecorded",
|
||||
aggregateType: "Inspection",
|
||||
aggregateId: "i1",
|
||||
occurredAt: "2026-10-13T08:00:00.000Z",
|
||||
attempts: 8,
|
||||
lastError: "boom",
|
||||
failedHandlers: [{ handler: "automation", lastError: "boom" }],
|
||||
};
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SystemPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SystemPage", () => {
|
||||
beforeEach(() => {
|
||||
permissions = ["system:read", "system:manage"];
|
||||
vi.mocked(fetchSystemOverview).mockResolvedValue(overview());
|
||||
vi.mocked(fetchFailedDomainEvents).mockResolvedValue([failedEvent]);
|
||||
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]);
|
||||
vi.mocked(retryDomainEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(retryWebhookDelivery).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("lists every job with its last run and status label", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "System" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("Tägliche Auswertung")).toBeInTheDocument();
|
||||
expect(screen.getByText("Aufgaben aus Vorlagen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Aufräumen")).toBeInTheDocument();
|
||||
expect(screen.getByText("App-Backup")).toBeInTheDocument();
|
||||
expect(screen.getByText("Schlüssel umstellen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Erfolgreich")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Noch nie gelaufen")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows the queue counts without a warning while the queue keeps up", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Offen: 3")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ältestes offenes Ereignis: vor 1 Min\./)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Der Worker arbeitet die Warteschlange gerade nicht ab."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when no event is open", async () => {
|
||||
vi.mocked(fetchSystemOverview).mockResolvedValue(
|
||||
overview({ pending: 0, oldestPendingAgeSeconds: null }),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Keine offenen Ereignisse")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when the oldest open event is older than 10 minutes", async () => {
|
||||
vi.mocked(fetchSystemOverview).mockResolvedValue(overview({ oldestPendingAgeSeconds: 601 }));
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Der Worker arbeitet die Warteschlange gerade nicht ab."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not warn at exactly 10 minutes", async () => {
|
||||
vi.mocked(fetchSystemOverview).mockResolvedValue(overview({ oldestPendingAgeSeconds: 600 }));
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Offen: 3");
|
||||
expect(
|
||||
screen.queryByText("Der Worker arbeitet die Warteschlange gerade nicht ab."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries a failed event and refetches the overview", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const row = (await screen.findByText("Durchsicht erfasst")).closest("li") as HTMLElement;
|
||||
await user.click(within(row).getByRole("button", { name: "Erneut versuchen" }));
|
||||
|
||||
await waitFor(() => expect(retryDomainEvent).toHaveBeenCalledWith("ev1"));
|
||||
expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht");
|
||||
await waitFor(() => expect(fetchSystemOverview).toHaveBeenCalledTimes(2));
|
||||
expect(fetchFailedDomainEvents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('hides "Erneut versuchen" without system:manage', async () => {
|
||||
permissions = ["system:read"];
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Durchsicht erfasst");
|
||||
expect(screen.queryByRole("button", { name: "Erneut versuchen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty states and retries a failed webhook delivery", async () => {
|
||||
vi.mocked(fetchFailedDomainEvents).mockResolvedValue([]);
|
||||
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([
|
||||
{
|
||||
id: "d1",
|
||||
subscriptionId: "s1",
|
||||
targetHost: "hooks.example.com",
|
||||
event: "harvest.created",
|
||||
attempts: 8,
|
||||
lastStatusCode: 500,
|
||||
lastError: "HTTP 500",
|
||||
createdAt: "2026-10-13T08:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Keine fehlgeschlagenen Ereignisse.")).toBeInTheDocument();
|
||||
expect(await screen.findByText("hooks.example.com")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Erneut versuchen" }));
|
||||
|
||||
await waitFor(() => expect(retryWebhookDelivery).toHaveBeenCalledWith("d1"));
|
||||
});
|
||||
|
||||
it("shows an error toast when the retry fails", async () => {
|
||||
vi.mocked(retryDomainEvent).mockRejectedValue(new ApiError(409, "Konflikt"));
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Erneut versuchen" }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Konflikt"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Activity, AlertTriangle, CircleCheck, CircleX, Loader } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchFailedDomainEvents,
|
||||
fetchFailedWebhookDeliveries,
|
||||
fetchSystemOverview,
|
||||
retryDomainEvent,
|
||||
retryWebhookDelivery,
|
||||
} from "@/lib/system/api";
|
||||
import {
|
||||
DOMAIN_EVENT_LABELS,
|
||||
JOB_LABELS,
|
||||
JOB_RUN_STATUS_LABELS,
|
||||
QUEUE_STALE_WARNING_SECONDS,
|
||||
attemptsLabel,
|
||||
formatAge,
|
||||
} from "@/lib/system/labels";
|
||||
import type { JobRunStatus, JobRunView, SystemOverview } from "@/lib/system/types";
|
||||
import { WEBHOOK_EVENT_LABELS } from "@/lib/webhooks/labels";
|
||||
import type { WebhookEvent } from "@/lib/webhooks/types";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
|
||||
const SYSTEM_QUERY_KEY = ["system"] as const;
|
||||
const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const;
|
||||
const FAILED_EVENTS_QUERY_KEY = [...SYSTEM_QUERY_KEY, "failed-events"] as const;
|
||||
const FAILED_DELIVERIES_QUERY_KEY = [...SYSTEM_QUERY_KEY, "failed-deliveries"] as const;
|
||||
|
||||
const STATUS_ICONS: Record<JobRunStatus, typeof CircleCheck> = {
|
||||
RUNNING: Loader,
|
||||
SUCCEEDED: CircleCheck,
|
||||
FAILED: CircleX,
|
||||
};
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
function eventLabel(type: string): string {
|
||||
return DOMAIN_EVENT_LABELS[type] ?? type;
|
||||
}
|
||||
|
||||
function webhookEventLabel(event: string): string {
|
||||
return WEBHOOK_EVENT_LABELS[event as WebhookEvent] ?? event;
|
||||
}
|
||||
|
||||
/** Status in words plus an icon - never colour alone. */
|
||||
function RunStatus({ status }: { status: JobRunStatus }) {
|
||||
const Icon = STATUS_ICONS[status];
|
||||
return (
|
||||
<Badge variant={status === "FAILED" ? "destructive" : "secondary"}>
|
||||
<Icon aria-hidden />
|
||||
{JOB_RUN_STATUS_LABELS[status]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function JobRunLine({ run }: { run: JobRunView | null }) {
|
||||
if (!run) {
|
||||
return <span className="text-sm text-muted-foreground">Noch nie gelaufen</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RunStatus status={run.status} />
|
||||
<span className="text-sm text-muted-foreground">{formatDateTime(run.startedAt)}</span>
|
||||
</div>
|
||||
{run.error ? <p className="break-words text-sm text-destructive">{run.error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobsSection({ jobs }: { jobs: SystemOverview["jobs"] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Hintergrundjobs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col divide-y divide-border/60">
|
||||
{jobs.map(({ job, lastRun }) => (
|
||||
<li
|
||||
key={job}
|
||||
className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
|
||||
>
|
||||
<span className="font-medium">{JOB_LABELS[job]}</span>
|
||||
<JobRunLine run={lastRun} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StaleQueueWarning() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden />
|
||||
<p className="font-medium">Der Worker arbeitet die Warteschlange gerade nicht ab.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedList({ emptyText, children }: { emptyText: string; children: ReactNode[] }) {
|
||||
if (children.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{emptyText}</p>;
|
||||
}
|
||||
return <ul className="flex flex-col gap-2">{children}</ul>;
|
||||
}
|
||||
|
||||
// No ConfirmDialog: a retry only re-queues the entry, it destroys nothing.
|
||||
interface RetryButtonProps {
|
||||
canManage: boolean;
|
||||
isPending: boolean;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function RetryButton({ canManage, isPending, onRetry }: RetryButtonProps) {
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button type="button" size="touch" variant="outline" disabled={isPending} onClick={onRetry}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function useRetry(mutationFn: (id: string) => Promise<void>) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => mutationFn(id),
|
||||
onSuccess: () => {
|
||||
toast.success("Wird erneut versucht");
|
||||
return queryClient.invalidateQueries({ queryKey: SYSTEM_QUERY_KEY });
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "Erneuter Versuch fehlgeschlagen")),
|
||||
});
|
||||
}
|
||||
|
||||
function FailedEvents({ canManage }: { canManage: boolean }) {
|
||||
const query = useQuery({ queryKey: FAILED_EVENTS_QUERY_KEY, queryFn: fetchFailedDomainEvents });
|
||||
const retry = useRetry(retryDomainEvent);
|
||||
|
||||
return (
|
||||
<QueryState query={query} what="Fehlgeschlagene Ereignisse">
|
||||
{(events) => (
|
||||
<FailedList emptyText="Keine fehlgeschlagenen Ereignisse.">
|
||||
{events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-start"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="font-medium">{eventLabel(event.type)}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateTime(event.occurredAt)} · {attemptsLabel(event.attempts)}
|
||||
</span>
|
||||
{event.failedHandlers.map((handler) => (
|
||||
<p key={handler.handler} className="break-words text-sm text-destructive">
|
||||
{handler.handler}
|
||||
{handler.lastError ? `: ${handler.lastError}` : ""}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<RetryButton
|
||||
canManage={canManage}
|
||||
isPending={retry.isPending && retry.variables === event.id}
|
||||
onRetry={() => retry.mutate(event.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</FailedList>
|
||||
)}
|
||||
</QueryState>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedDeliveries({ canManage }: { canManage: boolean }) {
|
||||
const query = useQuery({
|
||||
queryKey: FAILED_DELIVERIES_QUERY_KEY,
|
||||
queryFn: fetchFailedWebhookDeliveries,
|
||||
});
|
||||
const retry = useRetry(retryWebhookDelivery);
|
||||
|
||||
return (
|
||||
<QueryState query={query} what="Fehlgeschlagene Zustellungen">
|
||||
{(deliveries) => (
|
||||
<FailedList emptyText="Keine fehlgeschlagenen Zustellungen.">
|
||||
{deliveries.map((delivery) => (
|
||||
<li
|
||||
key={delivery.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-start"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="break-all font-mono text-sm">{delivery.targetHost}</span>
|
||||
<span className="text-sm">{webhookEventLabel(delivery.event)}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateTime(delivery.createdAt)} · {attemptsLabel(delivery.attempts)}
|
||||
{delivery.lastStatusCode ? ` · HTTP ${delivery.lastStatusCode}` : ""}
|
||||
</span>
|
||||
{delivery.lastError ? (
|
||||
<p className="break-words text-sm text-destructive">{delivery.lastError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<RetryButton
|
||||
canManage={canManage}
|
||||
isPending={retry.isPending && retry.variables === delivery.id}
|
||||
onRetry={() => retry.mutate(delivery.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</FailedList>
|
||||
)}
|
||||
</QueryState>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueSection({
|
||||
pending,
|
||||
failed,
|
||||
oldestPendingAgeSeconds,
|
||||
canManage,
|
||||
}: {
|
||||
pending: number;
|
||||
failed: number;
|
||||
oldestPendingAgeSeconds: number | null;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const isStale =
|
||||
oldestPendingAgeSeconds !== null && oldestPendingAgeSeconds > QUEUE_STALE_WARNING_SECONDS;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ereignis-Warteschlange</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isStale ? <StaleQueueWarning /> : null}
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
{pending === 0 || oldestPendingAgeSeconds === null ? (
|
||||
<p>Keine offenen Ereignisse</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">Offen: {pending}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Ältestes offenes Ereignis: {formatAge(oldestPendingAgeSeconds)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-muted-foreground">Fehlgeschlagen: {failed}</p>
|
||||
</div>
|
||||
<FailedEvents canManage={canManage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemPage() {
|
||||
const { user } = useAuth();
|
||||
const canManage = Boolean(user?.permissions.includes("system:manage"));
|
||||
const overviewQuery = useQuery({ queryKey: OVERVIEW_QUERY_KEY, queryFn: fetchSystemOverview });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
icon={Activity}
|
||||
title="System"
|
||||
description="Hier siehst du, ob die Hintergrundarbeit läuft."
|
||||
/>
|
||||
<QueryState query={overviewQuery} what="Systemdaten">
|
||||
{(overview) => (
|
||||
<>
|
||||
<JobsSection jobs={overview.jobs} />
|
||||
<QueueSection
|
||||
pending={overview.domainEvents.pending}
|
||||
failed={overview.domainEvents.failed}
|
||||
oldestPendingAgeSeconds={overview.domainEvents.oldestPendingAgeSeconds}
|
||||
canManage={canManage}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Webhook-Zustellungen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<p className="text-sm">
|
||||
Offen: {overview.webhookDeliveries.pending} · Fehlgeschlagen:{" "}
|
||||
{overview.webhookDeliveries.failed}
|
||||
</p>
|
||||
<FailedDeliveries canManage={canManage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,14 @@ import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
|
||||
import NewTreatmentPage from "./page";
|
||||
import { toast } from "sonner";
|
||||
import { createTreatment, fetchTreatmentPrefill } from "@/lib/treatments/api";
|
||||
import { createTreatmentsBulk } from "@/lib/bulk/api";
|
||||
import { queueBulk } from "@/lib/offline/db";
|
||||
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -16,7 +22,19 @@ vi.mock("@/lib/treatments/api", () => ({
|
||||
createTreatment: vi.fn(),
|
||||
fetchTreatmentPrefill: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn() }));
|
||||
vi.mock("@/lib/bulk/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/bulk/api")>()),
|
||||
createTreatmentsBulk: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn().mockResolvedValue([]),
|
||||
refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn(), queueBulk: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
/** jsdom reports online; a test can take the device offline until it ends. */
|
||||
function goOffline() {
|
||||
@@ -47,6 +65,12 @@ describe("NewTreatmentPage", () => {
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
|
||||
] as never);
|
||||
vi.mocked(createTreatment).mockReset();
|
||||
vi.mocked(createTreatmentsBulk).mockReset();
|
||||
vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(refreshPacksAfterSync).mockClear();
|
||||
vi.mocked(fetchApiaries)
|
||||
.mockReset()
|
||||
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
|
||||
vi.mocked(fetchTreatmentPrefill).mockReset().mockResolvedValue(null);
|
||||
});
|
||||
|
||||
@@ -297,4 +321,165 @@ describe("NewTreatmentPage", () => {
|
||||
expect(vi.mocked(createTreatment).mock.calls[0][0]).toMatchObject({ supplier: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("for several colonies", () => {
|
||||
const BULK_COLONIES = [
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
|
||||
{ id: "colony-2", code: "V-02", status: "WEAK", apiaryId: "apiary-1" },
|
||||
];
|
||||
const SAVED = { bulkId: "b-1", records: [] };
|
||||
const SAVE = { name: "Behandlung speichern" };
|
||||
|
||||
async function fillBulkForm() {
|
||||
vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never);
|
||||
renderPage();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
|
||||
await screen.findByText("2 von 2 Völkern gewählt");
|
||||
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
|
||||
await userEvent.type(screen.getByLabelText(/Chargennummer/), "Ch-4711");
|
||||
}
|
||||
|
||||
it("bulk mode sends one POST /treatments/bulk with colonyIds and the shared fields", async () => {
|
||||
vi.mocked(createTreatmentsBulk).mockResolvedValue(SAVED as never);
|
||||
await fillBulkForm();
|
||||
|
||||
expect(screen.getByText(/Gilt für alle gewählten Völker\./)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1));
|
||||
const [input, options] = vi.mocked(createTreatmentsBulk).mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
colonyIds: ["colony-1", "colony-2"],
|
||||
product: "Oxuvar",
|
||||
batchNumber: "Ch-4711",
|
||||
});
|
||||
expect(input).not.toHaveProperty("colonyId");
|
||||
expect(options?.idempotencyKey).toBeTruthy();
|
||||
expect(createTreatment).not.toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith("Behandlung für 2 Völker gespeichert");
|
||||
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
|
||||
});
|
||||
|
||||
it("sends only the colonies that stay selected", async () => {
|
||||
vi.mocked(createTreatmentsBulk).mockResolvedValue(SAVED as never);
|
||||
await fillBulkForm();
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createTreatmentsBulk).mock.calls[0][0].colonyIds).toEqual(["colony-1"]);
|
||||
});
|
||||
|
||||
it("asks for a colony instead of sending nothing", async () => {
|
||||
await fillBulkForm();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Alle abwählen" }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
expect(await screen.findByText("Bitte mindestens ein Volk wählen.")).toBeInTheDocument();
|
||||
expect(createTreatmentsBulk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offline queues exactly one bulk entry", async () => {
|
||||
vi.mocked(createTreatmentsBulk).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await fillBulkForm();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1));
|
||||
const [kind, input, key, owner] = vi.mocked(queueBulk).mock.calls[0];
|
||||
expect(kind).toBe("treatments");
|
||||
expect(input).toMatchObject({ colonyIds: ["colony-1", "colony-2"], product: "Oxuvar" });
|
||||
expect(key).toBe(vi.mocked(createTreatmentsBulk).mock.calls[0][1]?.idempotencyKey);
|
||||
expect(owner).toBe("user-1");
|
||||
expect(createTreatment).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"));
|
||||
});
|
||||
|
||||
it("a 400 names the ended colony by code and keeps the form", async () => {
|
||||
vi.mocked(createTreatmentsBulk).mockRejectedValue(
|
||||
new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
|
||||
invalidColonyIds: ["colony-2"],
|
||||
}),
|
||||
);
|
||||
await fillBulkForm();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Einige Völker sind nicht (mehr) erfassbar: V-02");
|
||||
expect(screen.getByLabelText(/^Mittel/)).toHaveValue("Oxuvar");
|
||||
expect(screen.getByLabelText(/Chargennummer/)).toHaveValue("Ch-4711");
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
expect(queueBulk).not.toHaveBeenCalled();
|
||||
// The pack of the refused colony is reloaded at once.
|
||||
await waitFor(() =>
|
||||
expect(refreshPacksAfterSync).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.any(Number),
|
||||
undefined,
|
||||
{ force: true, colonyIds: ["colony-2"] },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("„Ohne V-02 senden“ sends the rest under a key of its own", async () => {
|
||||
vi.mocked(createTreatmentsBulk)
|
||||
.mockRejectedValueOnce(
|
||||
new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
|
||||
invalidColonyIds: ["colony-2"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(SAVED as never);
|
||||
await fillBulkForm();
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ohne V-02 senden" }));
|
||||
|
||||
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(2));
|
||||
const [first, second] = vi.mocked(createTreatmentsBulk).mock.calls;
|
||||
expect(second[0].colonyIds).toEqual(["colony-1"]);
|
||||
expect(second[1]?.idempotencyKey).not.toBe(first[1]?.idempotencyKey);
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith("Behandlung für 1 Volk gespeichert"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows „wird bereits verarbeitet“ as a request to wait, not as an error", async () => {
|
||||
vi.mocked(createTreatmentsBulk).mockRejectedValue(
|
||||
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
|
||||
);
|
||||
await fillBulkForm();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Wird gerade verarbeitet. Bitte gleich noch einmal speichern."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("repeats the key for the same selection and renews it for another", async () => {
|
||||
vi.mocked(createTreatmentsBulk).mockRejectedValue(new ApiError(400, "Menge fehlt"));
|
||||
await fillBulkForm();
|
||||
const save = screen.getByRole("button", SAVE);
|
||||
|
||||
await userEvent.click(save);
|
||||
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1));
|
||||
await userEvent.click(save);
|
||||
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(2));
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" }));
|
||||
await userEvent.click(save);
|
||||
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(3));
|
||||
|
||||
const keys = vi
|
||||
.mocked(createTreatmentsBulk)
|
||||
.mock.calls.map(([, options]) => options?.idempotencyKey);
|
||||
expect(keys[1]).toBe(keys[0]);
|
||||
expect(keys[2]).not.toBe(keys[0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Syringe } from "lucide-react";
|
||||
import { createTreatment } from "@/lib/treatments/api";
|
||||
import { createTreatmentsBulk } from "@/lib/bulk/api";
|
||||
import { bulkDestination, bulkQueuedText, bulkSavedText, bulkSelectionError } from "@/lib/bulk/capture-texts";
|
||||
import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
|
||||
import { useBulkCapture } from "@/lib/bulk/use-bulk-capture";
|
||||
import { queueTreatment } from "@/lib/offline/db";
|
||||
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
|
||||
import { captureBackTarget } from "@/lib/forms/back-target";
|
||||
@@ -19,24 +23,35 @@ import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
|
||||
import { CaptureColonyField } from "@/components/form/capture-colony-field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
TreatmentFormValues,
|
||||
initialTreatmentValues,
|
||||
toBulkTreatmentInput,
|
||||
toTreatmentInput,
|
||||
} from "./treatment-form-values";
|
||||
|
||||
type RequiredKey = "colonyId" | "date" | "product";
|
||||
type RequiredKey = "colonyId" | "bulkColonies" | "date" | "product";
|
||||
type FieldErrors = Partial<Record<RequiredKey, string>>;
|
||||
|
||||
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date", "product"];
|
||||
const SHARED_NOTE = "Gilt für alle gewählten Völker.";
|
||||
const BATCH_HINT = "Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“.";
|
||||
|
||||
function requiredKeys(values: TreatmentFormValues): RequiredKey[] {
|
||||
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "product"];
|
||||
}
|
||||
|
||||
function validate(values: TreatmentFormValues): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
|
||||
if (values.target === "bulk") {
|
||||
errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds);
|
||||
} else if (!values.colonyId) {
|
||||
errors.colonyId = "Bitte ein Volk auswählen.";
|
||||
}
|
||||
if (!values.date) errors.date = "Bitte ein Datum angeben.";
|
||||
if (!values.product.trim()) errors.product = "Bitte das Mittel angeben.";
|
||||
return errors;
|
||||
@@ -69,11 +84,34 @@ export default function NewTreatmentPage() {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
markEdited(key);
|
||||
if ((REQUIRED_KEYS as string[]).includes(key)) {
|
||||
setFieldErrors((current) => ({ ...current, [key]: undefined }));
|
||||
}
|
||||
// Whatever was wrong with the field is wrong no longer; the colonies of a
|
||||
// bulk capture are one field in three values.
|
||||
const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key;
|
||||
setFieldErrors((current) => ({ ...current, [field]: undefined }));
|
||||
}
|
||||
|
||||
const isBulk = values.target === "bulk";
|
||||
const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null);
|
||||
|
||||
const bulkCapture = useBulkCapture({
|
||||
kind: "treatments",
|
||||
create: createTreatmentsBulk,
|
||||
failureMessage: "Behandlung konnte nicht gespeichert werden.",
|
||||
onSaved: (outcome, input) => {
|
||||
draft.clear();
|
||||
const count = input.colonyIds.length;
|
||||
if (outcome.via === "queued") {
|
||||
toast.info(bulkQueuedText("Behandlung", count));
|
||||
} else {
|
||||
for (const colonyId of input.colonyIds) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "treatments"] });
|
||||
}
|
||||
toast.success(bulkSavedText("Behandlung", count));
|
||||
}
|
||||
router.push(bulkDestination(values.bulkApiaryId));
|
||||
},
|
||||
});
|
||||
|
||||
const capture = useCaptureMutation({
|
||||
create: createTreatment,
|
||||
queue: queueTreatment,
|
||||
@@ -100,20 +138,35 @@ export default function NewTreatmentPage() {
|
||||
event.preventDefault();
|
||||
const errors = validate(values);
|
||||
setFieldErrors(errors);
|
||||
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||
const firstInvalid = requiredKeys(values).find((key) => errors[key]);
|
||||
if (firstInvalid) {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
if (isBulk) {
|
||||
bulkCapture.submit(toBulkTreatmentInput(values));
|
||||
return;
|
||||
}
|
||||
capture.submit(toTreatmentInput(values));
|
||||
}
|
||||
|
||||
/** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */
|
||||
function resendWithoutRefused() {
|
||||
const refused = new Set(bulkCapture.invalidColonyIds);
|
||||
const next = { ...values, bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)) };
|
||||
setValues(next);
|
||||
if (next.bulkColonyIds.length > 0) {
|
||||
bulkCapture.submit(toBulkTreatmentInput(next));
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
// A draft from before the bulk choice existed lacks its values.
|
||||
setValues({ ...initialTreatmentValues(""), ...restored });
|
||||
resetPrefill();
|
||||
setDirty(true);
|
||||
}
|
||||
@@ -148,11 +201,21 @@ export default function NewTreatmentPage() {
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Was, welches Volk, wann?">
|
||||
<LivingColonySelectField
|
||||
triggerId="treatment-colony"
|
||||
value={values.colonyId}
|
||||
onChange={(colonyId) => set("colonyId", colonyId)}
|
||||
error={fieldErrors.colonyId}
|
||||
<CaptureColonyField
|
||||
idPrefix="treatment"
|
||||
target={values.target}
|
||||
onTargetChange={(target) => set("target", target)}
|
||||
colonyId={values.colonyId}
|
||||
onColonyChange={(colonyId) => set("colonyId", colonyId)}
|
||||
colonyError={fieldErrors.colonyId}
|
||||
bulkApiaryId={values.bulkApiaryId}
|
||||
onBulkApiaryChange={(apiaryId) => {
|
||||
set("bulkApiaryId", apiaryId);
|
||||
set("bulkColonyIds", []);
|
||||
}}
|
||||
bulkColonyIds={values.bulkColonyIds}
|
||||
onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)}
|
||||
bulkError={fieldErrors.bulkColonies}
|
||||
/>
|
||||
<Field label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
@@ -234,7 +297,7 @@ export default function NewTreatmentPage() {
|
||||
</Field>
|
||||
<Field
|
||||
label="Chargennummer"
|
||||
hint="Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“."
|
||||
hint={isBulk ? `${BATCH_HINT} ${SHARED_NOTE}` : BATCH_HINT}
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
@@ -387,7 +450,16 @@ export default function NewTreatmentPage() {
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
capture.submitError ? (
|
||||
isBulk ? (
|
||||
<BulkSubmitNote
|
||||
submitError={bulkCapture.submitError}
|
||||
isRetryLater={bulkCapture.isRetryLater}
|
||||
invalidColonyIds={bulkCapture.invalidColonyIds}
|
||||
colonies={apiaryColonies.data?.items}
|
||||
isPending={bulkCapture.isPending}
|
||||
onResendWithout={resendWithoutRefused}
|
||||
/>
|
||||
) : capture.submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
@@ -400,9 +472,9 @@ export default function NewTreatmentPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={capture.isPending}
|
||||
disabled={capture.isPending || bulkCapture.isPending}
|
||||
>
|
||||
{capture.isPending
|
||||
{capture.isPending || bulkCapture.isPending
|
||||
? "Wird gespeichert…"
|
||||
: "Behandlung speichern"}
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { createTreatment } from "@/lib/treatments/api";
|
||||
import type { BulkCreateTreatmentInput } from "@/lib/bulk/api";
|
||||
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface TreatmentFormValues {
|
||||
colonyId: string;
|
||||
/** One colony, or the same treatment for several. */
|
||||
target: CaptureTarget;
|
||||
bulkApiaryId: string | null;
|
||||
bulkColonyIds: string[];
|
||||
date: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
@@ -24,6 +30,9 @@ export interface TreatmentFormValues {
|
||||
export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
|
||||
return {
|
||||
colonyId,
|
||||
target: "single",
|
||||
bulkApiaryId: null,
|
||||
bulkColonyIds: [],
|
||||
date: todayLocalIsoDate(),
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
@@ -41,11 +50,9 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
|
||||
};
|
||||
}
|
||||
|
||||
export function toTreatmentInput(
|
||||
values: TreatmentFormValues,
|
||||
): Parameters<typeof createTreatment>[0] {
|
||||
/** Everything of the treatment that is the same for one colony or for many. */
|
||||
function sharedTreatmentFields(values: TreatmentFormValues) {
|
||||
return {
|
||||
colonyId: values.colonyId,
|
||||
date: toApiDate(values.date),
|
||||
startDate: emptyToNull(values.startDate, toApiDate),
|
||||
endDate: emptyToNull(values.endDate, toApiDate),
|
||||
@@ -62,3 +69,14 @@ export function toTreatmentInput(
|
||||
prescribingVet: emptyToNull(values.prescribingVet),
|
||||
};
|
||||
}
|
||||
|
||||
export function toTreatmentInput(
|
||||
values: TreatmentFormValues,
|
||||
): Parameters<typeof createTreatment>[0] {
|
||||
return { colonyId: values.colonyId, ...sharedTreatmentFields(values) };
|
||||
}
|
||||
|
||||
/** One treatment for the chosen colonies; the batch number is what the user typed. */
|
||||
export function toBulkTreatmentInput(values: TreatmentFormValues): BulkCreateTreatmentInput {
|
||||
return { ...sharedTreatmentFields(values), colonyIds: values.bulkColonyIds };
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewVarroaMeasurementPage from "./page";
|
||||
import { toast } from "sonner";
|
||||
import { createVarroaMeasurement, fetchVarroaThreshold } from "@/lib/varroa/api";
|
||||
import { createVarroaBulk } from "@/lib/bulk/api";
|
||||
import { queueBulk } from "@/lib/offline/db";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
|
||||
const push = vi.fn();
|
||||
@@ -17,7 +21,19 @@ vi.mock("@/lib/varroa/api", async (importOriginal) => ({
|
||||
createVarroaMeasurement: vi.fn(),
|
||||
fetchVarroaThreshold: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn() }));
|
||||
vi.mock("@/lib/bulk/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/bulk/api")>()),
|
||||
createVarroaBulk: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn().mockResolvedValue([]),
|
||||
refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn(), queueBulk: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
@@ -38,6 +54,11 @@ describe("NewVarroaMeasurementPage", () => {
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
|
||||
] as never);
|
||||
vi.mocked(createVarroaMeasurement).mockReset();
|
||||
vi.mocked(createVarroaBulk).mockReset();
|
||||
vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(fetchApiaries)
|
||||
.mockReset()
|
||||
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
|
||||
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
|
||||
});
|
||||
|
||||
@@ -168,4 +189,119 @@ describe("NewVarroaMeasurementPage", () => {
|
||||
await userEvent.type(screen.getByLabelText(/^Tage/), "0");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("größer als 0");
|
||||
});
|
||||
|
||||
describe("for several colonies", () => {
|
||||
const BULK_COLONIES = [
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
|
||||
{ id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
|
||||
];
|
||||
const SAVE = { name: "Messung speichern" };
|
||||
|
||||
async function startBulk() {
|
||||
vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never);
|
||||
renderPage();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
|
||||
await screen.findByText("2 von 2 Völkern gewählt");
|
||||
}
|
||||
|
||||
it("bulk sends entries with each colony's own count", async () => {
|
||||
vi.mocked(createVarroaBulk).mockResolvedValue({ bulkId: "b-1", records: [] } as never);
|
||||
await startBulk();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
await waitFor(() => expect(createVarroaBulk).toHaveBeenCalledTimes(1));
|
||||
const [input, options] = vi.mocked(createVarroaBulk).mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
method: "NATURAL_MITE_FALL",
|
||||
durationMinutes: 4320,
|
||||
entries: [
|
||||
{ colonyId: "colony-1", miteCount: 30 },
|
||||
{ colonyId: "colony-2", miteCount: 9 },
|
||||
],
|
||||
});
|
||||
expect(input).not.toHaveProperty("miteCount");
|
||||
expect(input).not.toHaveProperty("colonyId");
|
||||
expect(options?.idempotencyKey).toBeTruthy();
|
||||
expect(createVarroaMeasurement).not.toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith("Varroamessung für 2 Völker gespeichert");
|
||||
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
|
||||
});
|
||||
|
||||
it("has no shared mite count and shows rate and light per colony", async () => {
|
||||
await startBulk();
|
||||
|
||||
expect(screen.queryByLabelText(/^Milbenzahl(?! V-)/)).not.toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "3");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
|
||||
|
||||
const first = screen.getByRole("group", { name: "V-01" });
|
||||
const second = screen.getByRole("group", { name: "V-02" });
|
||||
expect(await within(first).findByRole("status")).toHaveTextContent(
|
||||
"10,0 Milben/Tag – über der Schwelle",
|
||||
);
|
||||
expect(within(second).getByRole("status")).toHaveTextContent(
|
||||
"1,0 Milben/Tag – unter der Schwelle",
|
||||
);
|
||||
});
|
||||
|
||||
it("says in words which colonies still lack a count, and when none does", async () => {
|
||||
await startBulk();
|
||||
|
||||
expect(screen.getByText("Noch ohne Milbenzahl: V-01, V-02")).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
|
||||
expect(screen.getByText("Noch ohne Milbenzahl: V-02")).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9");
|
||||
|
||||
expect(screen.queryByText(/Noch ohne Milbenzahl/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Jedes Volk hat eine Milbenzahl.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks for a count for every selected colony", async () => {
|
||||
await startBulk();
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
expect(await screen.findByText("Bitte die Milbenzahl für V-02 eintragen.")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Milbenzahl V-02/)).toHaveFocus();
|
||||
expect(createVarroaBulk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offline queues exactly one bulk entry", async () => {
|
||||
vi.mocked(createVarroaBulk).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await startBulk();
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", SAVE));
|
||||
|
||||
await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(queueBulk).mock.calls[0][0]).toBe("varroa");
|
||||
expect(vi.mocked(queueBulk).mock.calls[0][1]).toMatchObject({
|
||||
entries: [
|
||||
{ colonyId: "colony-1", miteCount: 30 },
|
||||
{ colonyId: "colony-2", miteCount: 9 },
|
||||
],
|
||||
});
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"));
|
||||
});
|
||||
|
||||
it("keeps each colony's count when another colony is taken out and put back", async () => {
|
||||
await startBulk();
|
||||
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9");
|
||||
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "V-02" }));
|
||||
expect(screen.queryByLabelText(/Milbenzahl V-02/)).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "V-02" }));
|
||||
|
||||
expect(screen.getByLabelText(/Milbenzahl V-02/)).toHaveValue(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,16 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Bug } from "lucide-react";
|
||||
import { createVarroaMeasurement } from "@/lib/varroa/api";
|
||||
import { createVarroaBulk } from "@/lib/bulk/api";
|
||||
import {
|
||||
bulkDestination,
|
||||
bulkQueuedText,
|
||||
bulkSavedText,
|
||||
bulkSelectionError,
|
||||
} from "@/lib/bulk/capture-texts";
|
||||
import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
|
||||
import { useBulkCapture } from "@/lib/bulk/use-bulk-capture";
|
||||
import { BulkMiteCounts } from "@/components/varroa/bulk-mite-counts";
|
||||
import { mitesPerDay } from "@/lib/varroa/rate";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
|
||||
@@ -24,7 +34,8 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
|
||||
import { CaptureColonyField } from "@/components/form/capture-colony-field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
@@ -32,18 +43,29 @@ import {
|
||||
VARROA_METHOD_HINTS,
|
||||
VarroaFormValues,
|
||||
initialVarroaValues,
|
||||
toBulkVarroaInput,
|
||||
toVarroaInput,
|
||||
} from "./varroa-form-values";
|
||||
|
||||
type RequiredKey = "colonyId" | "date";
|
||||
type RequiredKey = "colonyId" | "bulkColonies" | "date";
|
||||
type FieldErrors = Partial<Record<RequiredKey, string>>;
|
||||
/** What is missing at each colony of a bulk count, by colony id. */
|
||||
type CountErrors = Record<string, string | undefined>;
|
||||
|
||||
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"];
|
||||
const SHARED_NOTE = "Gilt für alle gewählten Völker.";
|
||||
const VARROA_METHOD_OPTIONS = choiceOptions(VARROA_METHOD_LABELS, VARROA_METHODS);
|
||||
|
||||
function requiredKeys(values: VarroaFormValues): RequiredKey[] {
|
||||
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date"];
|
||||
}
|
||||
|
||||
function validate(values: VarroaFormValues): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
|
||||
if (values.target === "bulk") {
|
||||
errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds);
|
||||
} else if (!values.colonyId) {
|
||||
errors.colonyId = "Bitte ein Volk auswählen.";
|
||||
}
|
||||
if (!values.date) errors.date = "Bitte ein Datum angeben.";
|
||||
return errors;
|
||||
}
|
||||
@@ -62,6 +84,7 @@ export default function NewVarroaMeasurementPage() {
|
||||
);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [countErrors, setCountErrors] = useState<CountErrors>({});
|
||||
const draft = useFormDraft("varroa-new", values, dirty);
|
||||
|
||||
function set<K extends keyof VarroaFormValues>(
|
||||
@@ -70,11 +93,43 @@ export default function NewVarroaMeasurementPage() {
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setDirty(true);
|
||||
if ((REQUIRED_KEYS as string[]).includes(key)) {
|
||||
setFieldErrors((current) => ({ ...current, [key]: undefined }));
|
||||
}
|
||||
// The colonies of a bulk capture are one field in three values.
|
||||
const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key;
|
||||
setFieldErrors((current) => ({ ...current, [field]: undefined }));
|
||||
}
|
||||
|
||||
function setMiteCount(colonyId: string, count: string) {
|
||||
set("bulkMiteCounts", { ...values.bulkMiteCounts, [colonyId]: count });
|
||||
setCountErrors((current) => ({ ...current, [colonyId]: undefined }));
|
||||
}
|
||||
|
||||
const isBulk = values.target === "bulk";
|
||||
const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null);
|
||||
const chosenColonies = (apiaryColonies.data?.items ?? []).filter((colony) =>
|
||||
values.bulkColonyIds.includes(colony.id),
|
||||
);
|
||||
|
||||
const bulkCapture = useBulkCapture({
|
||||
kind: "varroa",
|
||||
create: createVarroaBulk,
|
||||
failureMessage: "Varroamessung konnte nicht gespeichert werden.",
|
||||
onSaved: (outcome, input) => {
|
||||
draft.clear();
|
||||
const count = input.entries.length;
|
||||
if (outcome.via === "queued") {
|
||||
toast.info(bulkQueuedText("Varroamessung", count));
|
||||
} else {
|
||||
for (const { colonyId } of input.entries) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", colonyId, "varroa-measurements"],
|
||||
});
|
||||
}
|
||||
toast.success(bulkSavedText("Varroamessung", count));
|
||||
}
|
||||
router.push(bulkDestination(values.bulkApiaryId));
|
||||
},
|
||||
});
|
||||
|
||||
const capture = useCaptureMutation({
|
||||
create: createVarroaMeasurement,
|
||||
queue: queueVarroaMeasurement,
|
||||
@@ -101,20 +156,57 @@ export default function NewVarroaMeasurementPage() {
|
||||
event.preventDefault();
|
||||
const errors = validate(values);
|
||||
setFieldErrors(errors);
|
||||
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
|
||||
const firstInvalid = requiredKeys(values).find((key) => errors[key]);
|
||||
if (firstInvalid) {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
if (isBulk) {
|
||||
submitBulk(values);
|
||||
return;
|
||||
}
|
||||
capture.submit(toVarroaInput(values));
|
||||
}
|
||||
|
||||
/** Every chosen colony needs its own count: an empty one would be a measurement of nothing. */
|
||||
function submitBulk(current: VarroaFormValues) {
|
||||
const missing = chosenColonies.filter(
|
||||
(colony) => (current.bulkMiteCounts[colony.id] ?? "").trim() === "",
|
||||
);
|
||||
setCountErrors(
|
||||
Object.fromEntries(
|
||||
missing.map((colony) => [colony.id, `Bitte die Milbenzahl für ${colony.code} eintragen.`]),
|
||||
),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-field="miteCount-${missing[0].id}"]`)
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
bulkCapture.submit(toBulkVarroaInput(current));
|
||||
}
|
||||
|
||||
/** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */
|
||||
function resendWithoutRefused() {
|
||||
const refused = new Set(bulkCapture.invalidColonyIds);
|
||||
const next = {
|
||||
...values,
|
||||
bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)),
|
||||
};
|
||||
setValues(next);
|
||||
if (next.bulkColonyIds.length > 0) {
|
||||
bulkCapture.submit(toBulkVarroaInput(next));
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
// A draft from before the bulk choice existed lacks its values.
|
||||
setValues({ ...initialVarroaValues(""), ...restored });
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
@@ -157,11 +249,21 @@ export default function NewVarroaMeasurementPage() {
|
||||
noValidate
|
||||
>
|
||||
<FormSection step={1} title="Welches Volk, wann?">
|
||||
<LivingColonySelectField
|
||||
triggerId="varroa-colony"
|
||||
value={values.colonyId}
|
||||
onChange={(colonyId) => set("colonyId", colonyId)}
|
||||
error={fieldErrors.colonyId}
|
||||
<CaptureColonyField
|
||||
idPrefix="varroa"
|
||||
target={values.target}
|
||||
onTargetChange={(target) => set("target", target)}
|
||||
colonyId={values.colonyId}
|
||||
onColonyChange={(colonyId) => set("colonyId", colonyId)}
|
||||
colonyError={fieldErrors.colonyId}
|
||||
bulkApiaryId={values.bulkApiaryId}
|
||||
onBulkApiaryChange={(apiaryId) => {
|
||||
set("bulkApiaryId", apiaryId);
|
||||
set("bulkColonyIds", []);
|
||||
}}
|
||||
bulkColonyIds={values.bulkColonyIds}
|
||||
onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)}
|
||||
bulkError={fieldErrors.bulkColonies}
|
||||
/>
|
||||
<Field label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
@@ -180,7 +282,11 @@ export default function NewVarroaMeasurementPage() {
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={2} title="Wie gemessen?">
|
||||
<FormSection
|
||||
step={2}
|
||||
title="Wie gemessen?"
|
||||
description={isBulk ? `Methode und Zeitraum. ${SHARED_NOTE}` : undefined}
|
||||
>
|
||||
<Field label="Methode" group hint={VARROA_METHOD_HINTS[values.method]}>
|
||||
{({ id, labelId, describedBy }) => (
|
||||
<ChoiceGroup
|
||||
@@ -196,7 +302,22 @@ export default function NewVarroaMeasurementPage() {
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={3} title="Ergebnis">
|
||||
<FormSection
|
||||
step={3}
|
||||
title="Ergebnis"
|
||||
description={isBulk ? "Eine Milbenzahl je Volk." : undefined}
|
||||
>
|
||||
{isBulk ? (
|
||||
<BulkMiteCounts
|
||||
colonies={chosenColonies}
|
||||
counts={values.bulkMiteCounts}
|
||||
onCountChange={setMiteCount}
|
||||
durationMinutes={values.durationMinutes}
|
||||
showsRate={isNaturalFall}
|
||||
threshold={threshold}
|
||||
errors={countErrors}
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
label="Milbenzahl"
|
||||
hint={
|
||||
@@ -218,6 +339,7 @@ export default function NewVarroaMeasurementPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{isNaturalFall ? (
|
||||
<>
|
||||
<VarroaPeriodField
|
||||
@@ -225,10 +347,7 @@ export default function NewVarroaMeasurementPage() {
|
||||
onChange={(minutes) => set("durationMinutes", minutes)}
|
||||
hint="Wie lange die Windel lag."
|
||||
/>
|
||||
<VarroaRateLight
|
||||
rate={rate}
|
||||
threshold={threshold}
|
||||
/>
|
||||
{isBulk ? null : <VarroaRateLight rate={rate} threshold={threshold} />}
|
||||
</>
|
||||
) : (
|
||||
<Field
|
||||
@@ -258,20 +377,22 @@ export default function NewVarroaMeasurementPage() {
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={2}
|
||||
fieldCount={isBulk ? 1 : 2}
|
||||
>
|
||||
<Field label="Einschätzung" hint="z. B. „unter Schadschwelle“">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={values.assessment}
|
||||
onChange={(event) => set("assessment", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Notizen">
|
||||
{isBulk ? null : (
|
||||
<Field label="Einschätzung" hint="z. B. „unter Schadschwelle“">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="h-11"
|
||||
value={values.assessment}
|
||||
onChange={(event) => set("assessment", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Notizen" hint={isBulk ? SHARED_NOTE : undefined}>
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
@@ -284,7 +405,16 @@ export default function NewVarroaMeasurementPage() {
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
capture.submitError ? (
|
||||
isBulk ? (
|
||||
<BulkSubmitNote
|
||||
submitError={bulkCapture.submitError}
|
||||
isRetryLater={bulkCapture.isRetryLater}
|
||||
invalidColonyIds={bulkCapture.invalidColonyIds}
|
||||
colonies={apiaryColonies.data?.items}
|
||||
isPending={bulkCapture.isPending}
|
||||
onResendWithout={resendWithoutRefused}
|
||||
/>
|
||||
) : capture.submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
@@ -297,9 +427,11 @@ export default function NewVarroaMeasurementPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={capture.isPending}
|
||||
disabled={capture.isPending || bulkCapture.isPending}
|
||||
>
|
||||
{capture.isPending ? "Wird gespeichert…" : "Messung speichern"}
|
||||
{capture.isPending || bulkCapture.isPending
|
||||
? "Wird gespeichert…"
|
||||
: "Messung speichern"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { createVarroaMeasurement } from "@/lib/varroa/api";
|
||||
import type { VarroaMethod } from "@/lib/varroa/types";
|
||||
import type { BulkCreateVarroaInput } from "@/lib/bulk/api";
|
||||
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface VarroaFormValues {
|
||||
colonyId: string;
|
||||
/** One colony, or one count for several - each with its own mites. */
|
||||
target: CaptureTarget;
|
||||
bulkApiaryId: string | null;
|
||||
bulkColonyIds: string[];
|
||||
/** The mite count typed for each colony of a bulk count, by colony id. */
|
||||
bulkMiteCounts: Record<string, string>;
|
||||
date: string;
|
||||
method: VarroaMethod;
|
||||
miteCount: string;
|
||||
@@ -29,6 +37,10 @@ export const VARROA_METHOD_HINTS: Record<VarroaMethod, string> = {
|
||||
export function initialVarroaValues(colonyId: string): VarroaFormValues {
|
||||
return {
|
||||
colonyId,
|
||||
target: "single",
|
||||
bulkApiaryId: null,
|
||||
bulkColonyIds: [],
|
||||
bulkMiteCounts: {},
|
||||
date: todayLocalIsoDate(),
|
||||
method: "NATURAL_MITE_FALL",
|
||||
miteCount: "",
|
||||
@@ -51,3 +63,21 @@ export function toVarroaInput(
|
||||
notes: emptyToNull(values.notes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One count for the chosen colonies: date, method, duration and notes are
|
||||
* shared, every colony brings its own mites. There is no shared mite count
|
||||
* and no shared assessment - a reading belongs to one colony.
|
||||
*/
|
||||
export function toBulkVarroaInput(values: VarroaFormValues): BulkCreateVarroaInput {
|
||||
return {
|
||||
date: toApiDate(values.date),
|
||||
method: values.method,
|
||||
durationMinutes: emptyToNull(values.durationMinutes, Number),
|
||||
notes: emptyToNull(values.notes),
|
||||
entries: values.bulkColonyIds.map((colonyId) => ({
|
||||
colonyId,
|
||||
miteCount: emptyToNull(values.bulkMiteCounts[colonyId] ?? "", Number),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import LoginPage from "./page";
|
||||
|
||||
const pushMock = vi.fn();
|
||||
@@ -14,12 +15,13 @@ vi.mock("next/navigation", () => ({
|
||||
const loginMock = vi.fn();
|
||||
const verifyMfaMock = vi.fn();
|
||||
const completeSsoMock = vi.fn();
|
||||
let signedInUser: { id: string } | null = null;
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
login: loginMock,
|
||||
verifyMfa: verifyMfaMock,
|
||||
completeSso: completeSsoMock,
|
||||
user: null,
|
||||
user: signedInUser,
|
||||
isLoading: false,
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
@@ -39,6 +41,8 @@ describe("LoginPage", () => {
|
||||
loginMock.mockReset();
|
||||
verifyMfaMock.mockReset();
|
||||
completeSsoMock.mockReset();
|
||||
signedInUser = null;
|
||||
reportRequestOutcome("reached-server");
|
||||
searchParamsMock.mockReturnValue(new URLSearchParams());
|
||||
ssoStatusRequestMock.mockReset().mockResolvedValue(false);
|
||||
ssoLoginUrlMock.mockClear();
|
||||
@@ -157,4 +161,32 @@ describe("LoginPage", () => {
|
||||
expect(verifyMfaMock).toHaveBeenCalledWith("sso-mfa-token", "123456");
|
||||
expect(pushMock).toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
|
||||
it("sends someone who is already signed in on to the dashboard instead of showing the form", () => {
|
||||
signedInUser = { id: "user-1" };
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
|
||||
it("does not redirect while nobody is signed in", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(replaceMock).not.toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
|
||||
it("says that signing in needs a connection when offline, but still lets the person try", async () => {
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Anmelden braucht eine Internetverbindung.");
|
||||
expect(screen.getByRole("button", { name: /anmelden/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows no connection hint while online", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.queryByText("Anmelden braucht eine Internetverbindung.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Hexagon } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { ssoLoginUrl, ssoStatusRequest } from "@/lib/auth/api";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -40,7 +41,8 @@ export default function LoginPage() {
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login, verifyMfa, completeSso } = useAuth();
|
||||
const { login, verifyMfa, completeSso, user, isLoading } = useAuth();
|
||||
const connectivity = useConnectivity();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
@@ -61,6 +63,15 @@ function LoginPageContent() {
|
||||
// waiting on a router.replace() round trip to first clear the param.
|
||||
const [isCompletingSso, setIsCompletingSso] = useState(ssoPending);
|
||||
|
||||
// Someone who is signed in (an offline session counts) has nothing to do
|
||||
// here; signing in on top of a running session would swap the account
|
||||
// under the offline queue.
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
router.replace("/dashboard");
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
ssoStatusRequest().then(setSsoEnabled);
|
||||
ssoLoginUrl().then(setSsoLoginHref);
|
||||
@@ -230,6 +241,11 @@ function LoginPageContent() {
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Always in the page, so the text is announced when it appears.
|
||||
The button stays usable: the network state can be stale. */}
|
||||
<p role="status" className="text-sm text-muted-foreground empty:hidden">
|
||||
{connectivity === "offline" ? "Anmelden braucht eine Internetverbindung." : null}
|
||||
</p>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Anmelden…" : "Anmelden"}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import { summarizeQueue } from "@/lib/offline/db";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { LogoutConfirmDialog } from "./logout-confirm-dialog";
|
||||
|
||||
const logoutMock = vi.fn();
|
||||
let sessionMode: "online" | "offline" = "online";
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ logout: logoutMock, sessionMode }),
|
||||
}));
|
||||
vi.mock("@/lib/offline/db", () => ({ summarizeQueue: vi.fn() }));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
|
||||
const NOTHING_WAITS = { own: 0, failed: 0, foreign: 0 };
|
||||
const onOpenChange = vi.fn();
|
||||
const onLoggedOut = vi.fn();
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<LogoutConfirmDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
userId="user-1"
|
||||
onLoggedOut={onLoggedOut}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LogoutConfirmDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logoutMock.mockResolvedValue(undefined);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue(NOTHING_WAITS);
|
||||
vi.mocked(syncPendingEntries).mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
reportRequestOutcome("reached-server");
|
||||
sessionMode = "online";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
reportRequestOutcome("reached-server");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("logs out directly when nothing waits", async () => {
|
||||
renderDialog();
|
||||
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("asks before deleting unsent entries", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 1, foreign: 5 });
|
||||
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
// Waiting and rejected entries of the person count; other accounts' do not.
|
||||
expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet.");
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Erst senden" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Abmelden und Einträge löschen" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("deletes the entries only after the destructive button was pressed", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
|
||||
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
|
||||
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("cancel leaves everything as it is", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends first and logs out once everything is gone", async () => {
|
||||
vi.mocked(summarizeQueue)
|
||||
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
|
||||
.mockResolvedValue(NOTHING_WAITS);
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
|
||||
});
|
||||
|
||||
it("stays open and says how many still wait when sending was not enough", async () => {
|
||||
vi.mocked(summarizeQueue)
|
||||
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
|
||||
.mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("dialog", { name: "Abmelden?" })).toHaveTextContent(
|
||||
"1 Eintrag ist noch nicht gesendet.",
|
||||
),
|
||||
);
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no sending without network and warns that signing out deletes the entries", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
|
||||
expect(dialog).toHaveTextContent("Ohne Netz");
|
||||
});
|
||||
|
||||
it("offline logout marks the logout as pending (through the auth context)", async () => {
|
||||
// The pending mark is the auth context's job; the dialog only asks for the logout.
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
|
||||
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
|
||||
});
|
||||
|
||||
it("shows an error and stays open when the logout fails", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
logoutMock.mockRejectedValue(new Error("boom"));
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
|
||||
expect(onLoggedOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing while it is closed", () => {
|
||||
render(
|
||||
<LogoutConfirmDialog
|
||||
open={false}
|
||||
onOpenChange={onOpenChange}
|
||||
userId="user-1"
|
||||
onLoggedOut={onLoggedOut}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(summarizeQueue).not.toHaveBeenCalled();
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no sending in an offline session, even when the network looks fine", async () => {
|
||||
sessionMode = "offline";
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
|
||||
expect(dialog).toHaveTextContent("Ohne Netz");
|
||||
});
|
||||
|
||||
it("shows the error with a retry when the direct logout of an empty queue fails", async () => {
|
||||
logoutMock.mockRejectedValueOnce(new Error("boom"));
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
|
||||
expect(dialog).not.toHaveTextContent("noch nicht gesendet");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
|
||||
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(2));
|
||||
expect(logoutMock).toHaveBeenLastCalledWith({ discardQueue: false });
|
||||
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { summarizeQueue } from "@/lib/offline/db";
|
||||
import { entriesNotSent } from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
|
||||
interface LogoutConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userId: string;
|
||||
/** The person is signed out: the caller moves on to the login page. */
|
||||
onLoggedOut: () => void;
|
||||
}
|
||||
|
||||
/** Waiting and rejected entries of the person; owner-less legacy ones count as theirs. */
|
||||
async function countUnsent(userId: string): Promise<number> {
|
||||
try {
|
||||
const { own, failed } = await summarizeQueue(userId);
|
||||
return own + failed;
|
||||
} catch {
|
||||
// The queue cannot be read, so it cannot be lost either.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing out deletes the person's unsent entries: on a shared phone they must
|
||||
* not be sent under the next account. So with entries waiting it asks first
|
||||
* (send them, delete them, or stay); with nothing waiting it signs out at once.
|
||||
* Renders nothing while closed.
|
||||
*/
|
||||
export function LogoutConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
userId,
|
||||
onLoggedOut,
|
||||
}: LogoutConfirmDialogProps) {
|
||||
const { logout, sessionMode } = useAuth();
|
||||
const connectivity = useConnectivity();
|
||||
const [unsent, setUnsent] = useState<number | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
|
||||
// Closed: forget what the last opening found (adjusted while rendering).
|
||||
if (wasOpen !== open) {
|
||||
setWasOpen(open);
|
||||
if (!open) {
|
||||
setUnsent(null);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut(discardQueue: boolean) {
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await logout({ discardQueue });
|
||||
onOpenChange(false);
|
||||
onLoggedOut();
|
||||
} catch {
|
||||
setError("Abmelden hat nicht geklappt. Bitte noch einmal versuchen.");
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let isCurrent = true;
|
||||
void countUnsent(userId).then((count) => {
|
||||
if (!isCurrent) {
|
||||
return;
|
||||
}
|
||||
if (count === 0) {
|
||||
void signOut(false);
|
||||
} else {
|
||||
setUnsent(count);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
// signOut only reads props that are part of this effect's trigger.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, userId]);
|
||||
|
||||
async function sendFirst() {
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await syncPendingEntries(userId);
|
||||
} catch {
|
||||
// Whatever could not be sent is counted below.
|
||||
}
|
||||
const remaining = await countUnsent(userId);
|
||||
if (remaining === 0) {
|
||||
await signOut(false);
|
||||
return;
|
||||
}
|
||||
setUnsent(remaining);
|
||||
setIsBusy(false);
|
||||
}
|
||||
|
||||
// An offline session has no token: nothing can be sent, whatever the network says.
|
||||
const isOnline = connectivity === "online" && sessionMode !== "offline";
|
||||
const hasEntries = unsent !== null && unsent > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open && (hasEntries || error !== null)} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Abmelden?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasEntries
|
||||
? `${entriesNotSent(unsent)} ${
|
||||
isOnline
|
||||
? "Sende sie zuerst, oder melde dich ab und lösche sie."
|
||||
: "Ohne Netz kannst du sie nicht senden. Beim Abmelden werden sie gelöscht."
|
||||
}`
|
||||
: "Du bist noch angemeldet."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={isBusy}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
{hasEntries && isOnline ? (
|
||||
<Button type="button" size="touch" disabled={isBusy} onClick={() => void sendFirst()}>
|
||||
Erst senden
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
disabled={isBusy}
|
||||
onClick={() => void signOut(hasEntries)}
|
||||
>
|
||||
{hasEntries ? "Abmelden und Einträge löschen" : "Erneut versuchen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ColonySelectField, LivingColonySelectField } from "./colony-select-field";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import type { ColonyStatus } from "@/lib/colonies/types";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
function colony(id: string, code: string, status: ColonyStatus) {
|
||||
return { id, code, status, apiaryId: "apiary-1" };
|
||||
@@ -44,9 +52,73 @@ async function openOptions() {
|
||||
return screen.findAllByRole("option");
|
||||
}
|
||||
|
||||
async function openOptionsOf(index: number) {
|
||||
await userEvent.click(screen.getAllByRole("combobox")[index]);
|
||||
return screen.findAllByRole("option");
|
||||
}
|
||||
|
||||
describe("LivingColonySelectField", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never);
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("offers the pack's living colonies while offline", async () => {
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(listPacks).mockResolvedValue([
|
||||
storedPack(
|
||||
offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
colonies: [packColony("p-2", "B2"), packColony("p-1", "B1", { status: "WEAK" })],
|
||||
}),
|
||||
),
|
||||
storedPack(
|
||||
offlinePack({
|
||||
apiaryId: "apiary-2",
|
||||
colonies: [packColony("p-3", "C1", { status: "DISSOLVED" }), packColony("p-4", "C2")],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Offline: nur Völker der geladenen Stände."),
|
||||
).toBeInTheDocument();
|
||||
const options = await openOptions();
|
||||
expect(options.map((option) => option.textContent)).toEqual(["B1 (Schwach)", "B2", "C2"]);
|
||||
expect(listPacks).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("tells how to work offline when no apiary was loaded for offline use", async () => {
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Kein Netz. Lade den Stand vorher mit „Für offline laden“."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shares the one colony request with every other colony list on the page", async () => {
|
||||
vi.mocked(fetchColonies).mockClear();
|
||||
withClient(
|
||||
<>
|
||||
<LivingHarness />
|
||||
<AllHarness initial="" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("combobox")[0]).toHaveTextContent("Volk wählen"));
|
||||
await openOptionsOf(0);
|
||||
expect(fetchColonies).toHaveBeenCalledTimes(1);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never offers pack colonies when the server refused the list", async () => {
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new ApiError(403, "Forbidden"));
|
||||
withClient(<LivingHarness />);
|
||||
|
||||
expect(await screen.findByText(/Völker konnten nicht geladen werden/)).toBeInTheDocument();
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers weak, queenless and conspicuous colonies - not only active ones", async () => {
|
||||
|
||||
@@ -5,25 +5,89 @@ import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import {
|
||||
OFFLINE_PACKS_QUERY_KEY,
|
||||
listPacks,
|
||||
livingColoniesFromPacks,
|
||||
} from "@/lib/apiaries/offline-pack";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
|
||||
const PLACEHOLDER = "Volk wählen";
|
||||
const LOADING_PLACEHOLDER = "Völker werden geladen…";
|
||||
const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
|
||||
const OFFLINE_PACK_HINT = "Offline: nur Völker der geladenen Stände.";
|
||||
const OFFLINE_NOT_LOADED_HINT = "Kein Netz. Lade den Stand vorher mit „Für offline laden“.";
|
||||
|
||||
/** What a colony choice needs - a server colony or one from an offline pack. */
|
||||
type ColonyChoice = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
|
||||
|
||||
function useColonies() {
|
||||
return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||
}
|
||||
|
||||
type ChoicesHint = "load-error" | "offline-not-loaded" | "from-packs";
|
||||
|
||||
const HINT_TEXTS: Record<ChoicesHint, string> = {
|
||||
"load-error": LOAD_ERROR_HINT,
|
||||
"offline-not-loaded": OFFLINE_NOT_LOADED_HINT,
|
||||
"from-packs": OFFLINE_PACK_HINT,
|
||||
};
|
||||
|
||||
interface LivingColonyChoices {
|
||||
colonies: readonly ColonyChoice[] | undefined;
|
||||
isLoading: boolean;
|
||||
hint: ChoicesHint | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every colony from the shared ["colonies"] query - one request for every
|
||||
* colony list on the page. Only when that failed for lack of network (never
|
||||
* on a server refusal) the living colonies of the user's offline packs step
|
||||
* in, from their own query: pack colonies are partial records and never go
|
||||
* into the shared cache.
|
||||
*/
|
||||
function useLivingColonyChoices(): LivingColonyChoices {
|
||||
const userId = useCurrentUserId();
|
||||
const server = useColonies();
|
||||
// failureReason: offline the retry pauses, so `error` stays unset.
|
||||
const isOffline =
|
||||
server.data === undefined && isNetworkError(server.error ?? server.failureReason);
|
||||
const packs = useQuery({
|
||||
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "living-colonies"],
|
||||
queryFn: async () => livingColoniesFromPacks(await listPacks(userId ?? "")),
|
||||
enabled: isOffline && userId !== null,
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
|
||||
if (!isOffline) {
|
||||
return {
|
||||
colonies: server.data,
|
||||
isLoading: server.isLoading,
|
||||
hint: server.isError && server.data === undefined ? "load-error" : null,
|
||||
};
|
||||
}
|
||||
const packColonies = packs.data && packs.data.length > 0 ? packs.data : undefined;
|
||||
let hint: ChoicesHint | null = "offline-not-loaded";
|
||||
if (packColonies) {
|
||||
hint = "from-packs";
|
||||
} else if (packs.isLoading) {
|
||||
hint = null;
|
||||
}
|
||||
return { colonies: packColonies, isLoading: packs.isLoading, hint };
|
||||
}
|
||||
|
||||
interface ColonyChoicesProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (colonyId: string) => void;
|
||||
/** The colonies on offer; `undefined` while loading. */
|
||||
options: readonly Colony[] | undefined;
|
||||
options: readonly ColonyChoice[] | undefined;
|
||||
/** Every known colony, so a chosen one is named even if it is not offered. */
|
||||
colonies: readonly Colony[] | undefined;
|
||||
colonies: readonly ColonyChoice[] | undefined;
|
||||
isLoading: boolean;
|
||||
dataField?: string;
|
||||
describedBy?: string;
|
||||
@@ -65,6 +129,9 @@ interface LivingColonySelectFieldProps {
|
||||
* Weak, conspicuous and queenless colonies are included - they need the most
|
||||
* attention - and none that has ended. A colony handed in by a link that has
|
||||
* ended since is still named, just not offered.
|
||||
*
|
||||
* Without network it offers the living colonies of the apiaries loaded for
|
||||
* offline use ("Für offline laden") and says so.
|
||||
*/
|
||||
export function LivingColonySelectField({
|
||||
triggerId,
|
||||
@@ -76,18 +143,25 @@ export function LivingColonySelectField({
|
||||
emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.",
|
||||
dataField = "colonyId",
|
||||
}: LivingColonySelectFieldProps) {
|
||||
const { data: colonies, isLoading, isError } = useColonies();
|
||||
const { colonies, isLoading, hint: choicesHint } = useLivingColonyChoices();
|
||||
const living = colonies?.filter(
|
||||
(colony) => isLivingColony(colony) && colony.id !== excludeId,
|
||||
);
|
||||
|
||||
function hint(): string | undefined {
|
||||
if (choicesHint) {
|
||||
return HINT_TEXTS[choicesHint];
|
||||
}
|
||||
return living?.length === 0 ? emptyHint : undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
id={triggerId}
|
||||
label={label}
|
||||
required
|
||||
error={error}
|
||||
hint={isError ? LOAD_ERROR_HINT : living?.length === 0 ? emptyHint : undefined}
|
||||
hint={hint()}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<ColonyChoices
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BulkColony } from "@/lib/bulk/use-apiary-colonies";
|
||||
|
||||
interface BulkSubmitNoteProps {
|
||||
submitError: string | null;
|
||||
isRetryLater: boolean;
|
||||
/** Colonies the server refused, by id. */
|
||||
invalidColonyIds: readonly string[];
|
||||
/** The colonies on offer, to name the refused ones by code. */
|
||||
colonies: readonly Pick<BulkColony, "id" | "code">[] | undefined;
|
||||
isPending: boolean;
|
||||
/** Takes the refused colonies out of the selection and saves again. */
|
||||
onResendWithout: () => void;
|
||||
}
|
||||
|
||||
function codesOf(
|
||||
ids: readonly string[],
|
||||
colonies: BulkSubmitNoteProps["colonies"],
|
||||
): string[] {
|
||||
return ids.flatMap((id) => colonies?.find((colony) => colony.id === id)?.code ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* What stands above the save button of a bulk capture. A refusal names the
|
||||
* colonies by code and offers „Ohne … senden“; „wird bereits verarbeitet“ is
|
||||
* only a request to wait. Null when there is nothing to say.
|
||||
*/
|
||||
export function BulkSubmitNote({
|
||||
submitError,
|
||||
isRetryLater,
|
||||
invalidColonyIds,
|
||||
colonies,
|
||||
isPending,
|
||||
onResendWithout,
|
||||
}: BulkSubmitNoteProps) {
|
||||
if (isRetryLater) {
|
||||
return (
|
||||
<p role="status" className="text-sm font-medium">
|
||||
Wird gerade verarbeitet. Bitte gleich noch einmal speichern.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!submitError) {
|
||||
return null;
|
||||
}
|
||||
const codes = codesOf(invalidColonyIds, colonies);
|
||||
const hasRefused = invalidColonyIds.length > 0;
|
||||
return (
|
||||
<>
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{submitError}
|
||||
{codes.length > 0 ? `: ${codes.join(", ")}` : ""}
|
||||
</p>
|
||||
{hasRefused ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={isPending}
|
||||
onClick={onResendWithout}
|
||||
>
|
||||
{codes.length > 0 ? `Ohne ${codes.join(", ")} senden` : "Ohne diese Völker senden"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import { CaptureTarget, CaptureTargetToggle } from "@/components/form/capture-target-toggle";
|
||||
import { ColonyMultiSelect } from "@/components/form/colony-multi-select";
|
||||
|
||||
interface CaptureColonyFieldProps {
|
||||
/** Prefix of the element ids, e.g. "treatment". */
|
||||
idPrefix: string;
|
||||
target: CaptureTarget;
|
||||
onTargetChange: (target: CaptureTarget) => void;
|
||||
colonyId: string;
|
||||
onColonyChange: (colonyId: string) => void;
|
||||
colonyError?: string | null;
|
||||
bulkApiaryId: string | null;
|
||||
onBulkApiaryChange: (apiaryId: string | null) => void;
|
||||
bulkColonyIds: readonly string[];
|
||||
onBulkColonyIdsChange: (ids: string[]) => void;
|
||||
bulkError?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The „Für“ choice of a capture form and what it needs: the single colony
|
||||
* select, or the stand with its colonies for the same entry on several.
|
||||
*/
|
||||
export function CaptureColonyField({
|
||||
idPrefix,
|
||||
target,
|
||||
onTargetChange,
|
||||
colonyId,
|
||||
onColonyChange,
|
||||
colonyError,
|
||||
bulkApiaryId,
|
||||
onBulkApiaryChange,
|
||||
bulkColonyIds,
|
||||
onBulkColonyIdsChange,
|
||||
bulkError,
|
||||
}: CaptureColonyFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<CaptureTargetToggle value={target} onChange={onTargetChange} />
|
||||
{target === "single" ? (
|
||||
<LivingColonySelectField
|
||||
triggerId={`${idPrefix}-colony`}
|
||||
value={colonyId}
|
||||
onChange={onColonyChange}
|
||||
error={colonyError}
|
||||
/>
|
||||
) : (
|
||||
<ColonyMultiSelect
|
||||
id={`${idPrefix}-colonies`}
|
||||
apiaryId={bulkApiaryId}
|
||||
onApiaryChange={onBulkApiaryChange}
|
||||
selectedIds={bulkColonyIds}
|
||||
onChange={onBulkColonyIdsChange}
|
||||
error={bulkError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { ChoiceGroup, ChoiceOption } from "@/components/form/choice-group";
|
||||
|
||||
/** One colony, or the same capture for several colonies at once. */
|
||||
export type CaptureTarget = "single" | "bulk";
|
||||
|
||||
const TARGET_OPTIONS: ChoiceOption<CaptureTarget>[] = [
|
||||
{ value: "single", label: "Ein Volk" },
|
||||
{ value: "bulk", label: "Mehrere Völker" },
|
||||
];
|
||||
|
||||
interface CaptureTargetToggleProps {
|
||||
value: CaptureTarget;
|
||||
onChange: (target: CaptureTarget) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Für: Ein Volk / Mehrere Völker“ at the top of a capture form. Always one
|
||||
* of the two, so it is not marked as required or optional.
|
||||
*/
|
||||
export function CaptureTargetToggle({ value, onChange }: CaptureTargetToggleProps) {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={labelId} className="text-sm font-medium">
|
||||
Für
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
labelledBy={labelId}
|
||||
options={TARGET_OPTIONS}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ColonyMultiSelect } from "./colony-multi-select";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import type { ColonyStatus } from "@/lib/colonies/types";
|
||||
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
function colony(id: string, code: string, status: ColonyStatus, apiaryId = "apiary-1") {
|
||||
return { id, code, status, apiaryId };
|
||||
}
|
||||
|
||||
const COLONIES = [
|
||||
colony("c-1", "V-01", "ACTIVE"),
|
||||
colony("c-2", "V-02", "WEAK"),
|
||||
colony("c-3", "V-03", "QUEENLESS"),
|
||||
colony("c-4", "V-04", "DISSOLVED"),
|
||||
colony("c-5", "W-01", "ACTIVE", "apiary-2"),
|
||||
];
|
||||
|
||||
function Harness({
|
||||
initialApiaryId = null,
|
||||
initialIds = [],
|
||||
error,
|
||||
onChangeSpy,
|
||||
}: {
|
||||
initialApiaryId?: string | null;
|
||||
initialIds?: string[];
|
||||
error?: string | null;
|
||||
onChangeSpy?: (ids: string[]) => void;
|
||||
}) {
|
||||
const [apiaryId, setApiaryId] = useState<string | null>(initialApiaryId);
|
||||
const [ids, setIds] = useState<string[]>(initialIds);
|
||||
return (
|
||||
<ColonyMultiSelect
|
||||
id="bulk-colonies"
|
||||
apiaryId={apiaryId}
|
||||
onApiaryChange={setApiaryId}
|
||||
selectedIds={ids}
|
||||
onChange={(next) => {
|
||||
onChangeSpy?.(next);
|
||||
setIds(next);
|
||||
}}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSelect(props: Parameters<typeof Harness>[0] = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Harness {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseApiary(name: string) {
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name }));
|
||||
}
|
||||
|
||||
describe("ColonyMultiSelect", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchApiaries)
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
{ id: "apiary-1", name: "Waldrand" },
|
||||
{ id: "apiary-2", name: "Obstwiese" },
|
||||
] as never);
|
||||
vi.mocked(fetchColonies)
|
||||
.mockReset()
|
||||
.mockImplementation((async (filter?: { apiaryId?: string }) =>
|
||||
COLONIES.filter((item) => item.apiaryId === filter?.apiaryId)) as never);
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("choosing an apiary preselects all its living colonies", async () => {
|
||||
renderSelect();
|
||||
|
||||
await chooseApiary("Waldrand");
|
||||
|
||||
const boxes = await screen.findAllByRole("checkbox");
|
||||
expect(boxes.map((box) => box.getAttribute("aria-checked"))).toEqual(["true", "true", "true"]);
|
||||
// The weak and the queenless colony are in, the dissolved one never appears.
|
||||
expect(screen.getByRole("checkbox", { name: "V-02 (Schwach)" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "V-03 (Weisellos)" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox", { name: /V-04/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox", { name: /W-01/ })).not.toBeInTheDocument();
|
||||
expect(fetchColonies).toHaveBeenCalledWith({ apiaryId: "apiary-1" });
|
||||
expect(await screen.findByText("3 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deselecting one keeps the rest", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderSelect({ onChangeSpy: onChange });
|
||||
await chooseApiary("Waldrand");
|
||||
await screen.findByText("3 von 3 Völkern gewählt");
|
||||
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" }));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(["c-1", "c-3"]);
|
||||
expect(screen.getByRole("checkbox", { name: "V-02 (Schwach)" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByRole("checkbox", { name: "V-01" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByText("2 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rows are 56 px and checkboxes are named by colony code", async () => {
|
||||
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-1"] });
|
||||
|
||||
const box = await screen.findByRole("checkbox", { name: "V-01" });
|
||||
|
||||
expect(box.closest("label")).toHaveClass("min-h-14");
|
||||
expect(screen.getAllByRole("checkbox").map((item) => item.closest("label"))).toSatisfy(
|
||||
(rows: (HTMLElement | null)[]) => rows.every((row) => row?.classList.contains("min-h-14")),
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles a colony when its row text is tapped", async () => {
|
||||
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-2"] });
|
||||
await screen.findByRole("checkbox", { name: "V-01" });
|
||||
|
||||
await userEvent.click(screen.getByText("V-01"));
|
||||
|
||||
expect(screen.getByRole("checkbox", { name: "V-01" })).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("Alle abwählen and Alle wählen set the whole apiary at once", async () => {
|
||||
renderSelect();
|
||||
await chooseApiary("Waldrand");
|
||||
await screen.findByText("3 von 3 Völkern gewählt");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Alle abwählen" }));
|
||||
expect(screen.getByText("0 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Alle wählen" }));
|
||||
expect(screen.getByText("3 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a restored selection instead of preselecting everything", async () => {
|
||||
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-3"] });
|
||||
|
||||
expect(await screen.findByText("1 von 3 Völkern gewählt")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "V-03 (Weisellos)" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("preselects the colonies of the other apiary after switching", async () => {
|
||||
renderSelect();
|
||||
await chooseApiary("Waldrand");
|
||||
await screen.findByText("3 von 3 Völkern gewählt");
|
||||
|
||||
await chooseApiary("Obstwiese");
|
||||
|
||||
expect(await screen.findByRole("checkbox", { name: "W-01" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(screen.queryByRole("checkbox", { name: "V-01" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("1 von 1 Volk gewählt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the loaded packs while offline", async () => {
|
||||
vi.mocked(fetchApiaries).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(listPacks).mockResolvedValue([
|
||||
storedPack(
|
||||
offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
apiaryName: "Waldrand",
|
||||
colonies: [packColony("p-1", "B1"), packColony("p-2", "B2", { status: "DISSOLVED" })],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
renderSelect();
|
||||
|
||||
await chooseApiary("Waldrand");
|
||||
|
||||
expect(await screen.findByRole("checkbox", { name: "B1" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(screen.queryByRole("checkbox", { name: "B2" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Offline: nur Völker der geladenen Stände.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells how to work offline when no apiary was loaded", async () => {
|
||||
vi.mocked(fetchApiaries).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderSelect();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Kein Netz. Lade den Stand vorher mit „Für offline laden“."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the error next to the list", async () => {
|
||||
renderSelect({ error: "Bitte mindestens ein Volk wählen." });
|
||||
|
||||
expect(await screen.findByText("Bitte mindestens ein Volk wählen.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the limit when more than 100 colonies are selected", async () => {
|
||||
const many = Array.from({ length: 101 }, (_, index) =>
|
||||
colony(`c-${index}`, `V-${index}`, "ACTIVE"),
|
||||
);
|
||||
vi.mocked(fetchColonies).mockResolvedValue(many as never);
|
||||
renderSelect();
|
||||
|
||||
await chooseApiary("Waldrand");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Höchstens 100 Völker auf einmal.")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText("101 von 101 Völkern gewählt")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
import { BULK_MAX_COLONIES } from "@/lib/bulk/api";
|
||||
import { useApiaryChoices, useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
|
||||
const APIARY_PLACEHOLDER = "Stand wählen";
|
||||
const LOADING_PLACEHOLDER = "Stände werden geladen…";
|
||||
const LOAD_ERROR_HINT = "Stände konnten nicht geladen werden – bitte die Seite neu laden.";
|
||||
const COLONIES_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
|
||||
const OFFLINE_PACK_HINT = "Offline: nur Völker der geladenen Stände.";
|
||||
const OFFLINE_NOT_LOADED_HINT = "Kein Netz. Lade den Stand vorher mit „Für offline laden“.";
|
||||
const NO_COLONIES_HINT = "In diesem Stand gibt es noch keine aktiven Völker.";
|
||||
const MAX_COLONIES_ERROR = `Höchstens ${BULK_MAX_COLONIES} Völker auf einmal.`;
|
||||
|
||||
export interface ColonyMultiSelectProps {
|
||||
/** Base id; the stand select carries `${id}-apiary`. */
|
||||
id: string;
|
||||
apiaryId: string | null;
|
||||
onApiaryChange: (apiaryId: string | null) => void;
|
||||
selectedIds: readonly string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function countText(selected: number, total: number): string {
|
||||
return `${selected} von ${total} ${total === 1 ? "Volk" : "Völkern"} gewählt`;
|
||||
}
|
||||
|
||||
function errorHint(error: unknown, noun: "Stände" | "Völker"): string {
|
||||
if (isNetworkError(error)) return OFFLINE_NOT_LOADED_HINT;
|
||||
return noun === "Stände" ? LOAD_ERROR_HINT : COLONIES_ERROR_HINT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the colonies one capture is recorded for: first the stand, which
|
||||
* selects all its living colonies at once (ended ones never appear), then
|
||||
* single colonies can be taken out. Like the single-colony select it reads
|
||||
* the loaded offline packs when there is no network.
|
||||
*/
|
||||
export function ColonyMultiSelect({
|
||||
id,
|
||||
apiaryId,
|
||||
onApiaryChange,
|
||||
selectedIds,
|
||||
onChange,
|
||||
error,
|
||||
}: ColonyMultiSelectProps) {
|
||||
const apiaries = useApiaryChoices();
|
||||
const colonies = useApiaryColonies(apiaryId);
|
||||
const groupLabelId = useId();
|
||||
const list = colonies.data?.items;
|
||||
|
||||
// The stand a selection was made or restored for: only a different stand
|
||||
// preselects again, so a restored draft keeps its own choice.
|
||||
const preselectedFor = useRef<string | null>(selectedIds.length > 0 ? apiaryId : null);
|
||||
useEffect(() => {
|
||||
if (!apiaryId || !list) return;
|
||||
if (preselectedFor.current !== apiaryId) {
|
||||
preselectedFor.current = apiaryId;
|
||||
onChange(list.map((colony) => colony.id));
|
||||
return;
|
||||
}
|
||||
// A colony that is no longer on offer (ended since a draft) is not sent.
|
||||
const offered = new Set(list.map((colony) => colony.id));
|
||||
const kept = selectedIds.filter((colonyId) => offered.has(colonyId));
|
||||
if (kept.length !== selectedIds.length) {
|
||||
onChange(kept);
|
||||
}
|
||||
}, [apiaryId, list, selectedIds, onChange]);
|
||||
|
||||
function hint(): string | undefined {
|
||||
if (apiaries.isError) return errorHint(apiaries.error, "Stände");
|
||||
if (apiaryId && colonies.isError) return errorHint(colonies.error, "Völker");
|
||||
if (apiaries.data?.source === "packs" || colonies.data?.source === "packs") {
|
||||
return OFFLINE_PACK_HINT;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const offered = new Set((list ?? []).map((colony) => colony.id));
|
||||
const selectedCount = selectedIds.filter((colonyId) => offered.has(colonyId)).length;
|
||||
const shownError = selectedIds.length > BULK_MAX_COLONIES ? MAX_COLONIES_ERROR : error;
|
||||
|
||||
function toggle(colonyId: string, isChecked: boolean) {
|
||||
const next = isChecked
|
||||
? [...selectedIds, colonyId]
|
||||
: selectedIds.filter((selectedId) => selectedId !== colonyId);
|
||||
// Keep the order of the list, not the order of tapping.
|
||||
onChange((list ?? []).map((colony) => colony.id).filter((item) => next.includes(item)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field id={`${id}-apiary`} label="Stand" required hint={hint()}>
|
||||
{({ id: triggerId, describedBy, invalid }) => (
|
||||
<RecordSelect
|
||||
id={triggerId}
|
||||
value={apiaryId ?? ""}
|
||||
onChange={(next) => onApiaryChange(next === "" ? null : next)}
|
||||
items={apiaries.data?.items}
|
||||
dataField="bulkColonies"
|
||||
getLabel={(apiary) => apiary.name}
|
||||
placeholder={apiaries.isLoading ? LOADING_PLACEHOLDER : APIARY_PLACEHOLDER}
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{list ? (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={groupLabelId}
|
||||
aria-describedby={shownError ? `${id}-error` : undefined}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span id={groupLabelId} className="text-sm font-medium">
|
||||
Völker
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => onChange(list.map((colony) => colony.id))}
|
||||
>
|
||||
Alle wählen
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => onChange([])}>
|
||||
Alle abwählen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{NO_COLONIES_HINT}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.map((colony) => (
|
||||
<li key={colony.id}>
|
||||
<label
|
||||
htmlFor={`${id}-${colony.id}`}
|
||||
className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${id}-${colony.id}`}
|
||||
className="size-6"
|
||||
aria-labelledby={`${id}-${colony.id}-label`}
|
||||
checked={selectedIds.includes(colony.id)}
|
||||
onCheckedChange={(isChecked) => toggle(colony.id, isChecked)}
|
||||
/>
|
||||
<span id={`${id}-${colony.id}-label`} className="text-base">
|
||||
{colonyOptionLabel(colony)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p role="status" aria-live="polite" className="text-sm font-medium">
|
||||
{countText(selectedCount, list.length)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{shownError ? (
|
||||
<p id={`${id}-error`} role="alert" className="text-sm font-medium text-destructive">
|
||||
{shownError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { deleteAllPacks, getPack } from "@/lib/apiaries/offline-pack";
|
||||
import { DAY_MS, offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import { LoadPackButton } from "./load-pack-button";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const postMessage = vi.fn();
|
||||
|
||||
function renderButton() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<LoadPackButton apiaryId="apiary-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoadPackButton", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(toast.error).mockReset();
|
||||
postMessage.mockReset();
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: { ready: Promise.resolve({ active: { postMessage } }) },
|
||||
});
|
||||
await deleteAllPacks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
});
|
||||
|
||||
it("loads the apiary for offline use and then says how long ago", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
expect(await screen.findByText("Geladen vor weniger als einer Minute")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Geladen vor weniger als einer Minute");
|
||||
expect((await getPack("apiary-1"))?.ownerUserId).toBe("user-1");
|
||||
// The app shell is refreshed too, so the offline pages open without network.
|
||||
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledWith({ type: "WARM_SHELL" }));
|
||||
});
|
||||
|
||||
it("asks once that this device's offline data is kept, not evicted", async () => {
|
||||
const persist = vi.fn(async () => true);
|
||||
const persisted = vi.fn(async () => false);
|
||||
Object.defineProperty(navigator, "storage", {
|
||||
configurable: true,
|
||||
value: { persist, persisted },
|
||||
});
|
||||
try {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(1));
|
||||
} finally {
|
||||
Reflect.deleteProperty(navigator, "storage");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not ask again when the storage is already kept", async () => {
|
||||
const persist = vi.fn(async () => true);
|
||||
Object.defineProperty(navigator, "storage", {
|
||||
configurable: true,
|
||||
value: { persist, persisted: vi.fn(async () => true) },
|
||||
});
|
||||
try {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
await screen.findByText("Geladen vor weniger als einer Minute");
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Reflect.deleteProperty(navigator, "storage");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the stale hint for a pack older than 24 h", async () => {
|
||||
const db = await getDb();
|
||||
await db.put(
|
||||
"offline-packs",
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), {
|
||||
savedAt: new Date(Date.now() - 3 * DAY_MS).toISOString(),
|
||||
}),
|
||||
);
|
||||
renderButton();
|
||||
|
||||
expect(await screen.findByText("Geladen vor 3 Tagen")).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Stand-Daten von .* – bei Netz neu laden$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats another account's pack as not loaded", async () => {
|
||||
const db = await getDb();
|
||||
await db.put(
|
||||
"offline-packs",
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
renderButton();
|
||||
|
||||
await screen.findByRole("button", { name: "Für offline laden" });
|
||||
expect(screen.queryByText(/^Geladen vor/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when there is no network", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Kein Netz – der Stand konnte nicht geladen werden."),
|
||||
);
|
||||
expect(await getPack("apiary-1")).toBeNull();
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a server refusal differently from a missing network", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new ApiError(403, "Forbidden"));
|
||||
renderButton();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Stand konnte nicht für offline geladen werden. Bitte noch einmal versuchen.",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import {
|
||||
OFFLINE_PACKS_QUERY_KEY,
|
||||
getOwnPack,
|
||||
isPackStale,
|
||||
loadPack,
|
||||
ownPackQueryKey,
|
||||
packAgeText,
|
||||
packDayText,
|
||||
} from "@/lib/apiaries/offline-pack";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
|
||||
const NO_NETWORK_TEXT = "Kein Netz – der Stand konnte nicht geladen werden.";
|
||||
const LOAD_FAILED_TEXT = "Stand konnte nicht für offline geladen werden. Bitte noch einmal versuchen.";
|
||||
|
||||
interface PackAgeNoteProps {
|
||||
stored: StoredPack;
|
||||
/** When the pack was read from storage (the query's `dataUpdatedAt`). */
|
||||
now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the pack was loaded, and from 24 h on that its data is old - in words
|
||||
* and with an icon, never by colour alone.
|
||||
*/
|
||||
export function PackAgeNote({ stored, now }: PackAgeNoteProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<p className="text-muted-foreground">Geladen {packAgeText(stored.savedAt, now)}</p>
|
||||
{isPackStale(stored, now) ? (
|
||||
<p className="flex items-center gap-1.5 font-medium">
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
|
||||
{`Stand-Daten von ${packDayText(stored.savedAt)} – bei Netz neu laden`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Asks the service worker to refresh the app shell, so the offline pages open without network. */
|
||||
function requestShellWarmUp(): void {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
void navigator.serviceWorker.ready
|
||||
.then((registration) => registration.active?.postMessage({ type: "WARM_SHELL" }))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the browser once to keep this site's storage (packs, the offline
|
||||
* queue) instead of evicting it under storage pressure. Best effort.
|
||||
*/
|
||||
function requestPersistentStorage(): void {
|
||||
const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
|
||||
if (!storage?.persist || !storage.persisted) {
|
||||
return;
|
||||
}
|
||||
void storage
|
||||
.persisted()
|
||||
.then((isPersisted) => (isPersisted ? undefined : storage.persist()))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** The pack of this apiary, if the signed-in user loaded it. */
|
||||
export function useOwnPack(apiaryId: string, userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ownPackQueryKey(userId, apiaryId),
|
||||
queryFn: () => getOwnPack(apiaryId, userId ?? ""),
|
||||
enabled: userId !== null && apiaryId !== "",
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* „Für offline laden“: keeps the apiary's colonies, queens, last inspections
|
||||
* and open tasks on this device for work without reception, and says how
|
||||
* old what is kept is.
|
||||
*/
|
||||
export function LoadPackButton({ apiaryId }: { apiaryId: string }) {
|
||||
const userId = useCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
const isOffline = useConnectivity() === "offline";
|
||||
const { data: stored, dataUpdatedAt } = useOwnPack(apiaryId, userId);
|
||||
|
||||
const load = useMutation({
|
||||
mutationFn: (ownerUserId: string) => loadPack(apiaryId, ownerUserId),
|
||||
networkMode: "always",
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_PACKS_QUERY_KEY });
|
||||
requestShellWarmUp();
|
||||
requestPersistentStorage();
|
||||
},
|
||||
onError: (error) => toast.error(isNetworkError(error) ? NO_NETWORK_TEXT : LOAD_FAILED_TEXT),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={userId === null || isOffline || load.isPending}
|
||||
onClick={() => {
|
||||
if (userId) load.mutate(userId);
|
||||
}}
|
||||
>
|
||||
<Download aria-hidden />
|
||||
{load.isPending ? "Wird geladen…" : "Für offline laden"}
|
||||
</Button>
|
||||
{/* Always mounted, so a newly loaded pack is announced. */}
|
||||
<div role="status" aria-live="polite">
|
||||
{stored ? <PackAgeNote stored={stored} now={dataUpdatedAt} /> : null}
|
||||
{isOffline ? <p className="text-sm text-muted-foreground">Laden braucht Netz.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,11 +34,11 @@ function setOnLine(value: boolean) {
|
||||
}
|
||||
|
||||
/** The band as the layout places it: above the page content wrapper. */
|
||||
function BandOnPage() {
|
||||
function BandOnPage({ syncEnabled }: { syncEnabled?: boolean }) {
|
||||
const pageContentRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<>
|
||||
<OfflineBand userId="user-1" pageContentRef={pageContentRef} />
|
||||
<OfflineBand userId="user-1" pageContentRef={pageContentRef} syncEnabled={syncEnabled} />
|
||||
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
|
||||
Seiteninhalt
|
||||
</div>
|
||||
@@ -46,11 +46,11 @@ function BandOnPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderBand() {
|
||||
function renderBand(props: { syncEnabled?: boolean } = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<BandOnPage />
|
||||
<BandOnPage {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -260,4 +260,39 @@ describe("OfflineBand", () => {
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
});
|
||||
|
||||
describe("in an offline session (no access token)", () => {
|
||||
it("neither opens the sync gate nor sends, even when the connection is back", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand({ syncEnabled: false });
|
||||
await screen.findByText("Offline – 2 Einträge warten");
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("online"));
|
||||
});
|
||||
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: /Jetzt senden/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the gate and sends as soon as the session is online again", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = (syncEnabled: boolean) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<BandOnPage syncEnabled={syncEnabled} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(view(false));
|
||||
await screen.findByText("Offline – 1 Eintrag wartet");
|
||||
|
||||
rerender(view(true));
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
expect(resumeSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@ import { PendingQueueSheet } from "./pending-queue-sheet";
|
||||
const COUNT_INTERVAL_MS = 15_000;
|
||||
const EMPTY_QUEUE: QueueSummary = { own: 0, failed: 0, foreign: 0 };
|
||||
// Both auto-sync triggers can fire together; they share one sync run
|
||||
// (syncPendingEntries), and one toast id keeps it to one message.
|
||||
// (syncPendingEntries), even one already running - they change nothing that
|
||||
// is to be sent - and one toast id keeps it to one message.
|
||||
const SYNC_TOAST_ID = "offline-sync";
|
||||
// Wraps on narrow phones instead of overflowing; never below 44 px.
|
||||
const BAND_BUTTON_CLASS = "h-auto min-h-11 whitespace-normal text-left";
|
||||
@@ -26,6 +27,12 @@ interface OfflineBandProps {
|
||||
userId: string;
|
||||
/** Focus target when the queue sheet closes and the band shows nothing. */
|
||||
pageContentRef: RefObject<HTMLElement | null>;
|
||||
/**
|
||||
* False in an offline session (opened from the cached identity, no access
|
||||
* token): the band then shows what waits but neither opens the sync gate
|
||||
* nor sends - the server would only answer 401.
|
||||
*/
|
||||
syncEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +40,7 @@ interface OfflineBandProps {
|
||||
* device, how much still waits to be sent and what the server rejected.
|
||||
* Sends by itself as soon as the connection is back.
|
||||
*/
|
||||
export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
|
||||
export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: OfflineBandProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const connectivity = useConnectivity();
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
@@ -68,24 +75,31 @@ export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
|
||||
|
||||
// The band only exists for a signed-in user, and the auth context sets that
|
||||
// user's access token before the user: the moment to reopen sync after a
|
||||
// logout's cancelSync(). Declared first, so it runs before the sync below.
|
||||
// logout's cancelSync(). Not without a token (offline session): the gate
|
||||
// stays closed until the session is online. Declared first, so it runs
|
||||
// before the sync below.
|
||||
useEffect(() => {
|
||||
resumeSync();
|
||||
}, [userId]);
|
||||
if (syncEnabled) {
|
||||
resumeSync();
|
||||
}
|
||||
}, [userId, syncEnabled]);
|
||||
|
||||
// Mount, and every switch back to online (a request reached the server again).
|
||||
useEffect(() => {
|
||||
if (connectivity === "online") {
|
||||
if (syncEnabled && connectivity === "online") {
|
||||
startSync();
|
||||
}
|
||||
}, [connectivity, startSync, userId]);
|
||||
}, [connectivity, startSync, userId, syncEnabled]);
|
||||
|
||||
// The browser's own `online` event: try even while the last request failed.
|
||||
useEffect(() => {
|
||||
if (!syncEnabled) {
|
||||
return;
|
||||
}
|
||||
const handleOnline = () => startSync();
|
||||
window.addEventListener("online", handleOnline);
|
||||
return () => window.removeEventListener("online", handleOnline);
|
||||
}, [startSync]);
|
||||
}, [startSync, syncEnabled]);
|
||||
|
||||
// The sheet stays open while the user still has entries in it (waiting
|
||||
// ones too, after the last rejected one was discarded), and closes once
|
||||
@@ -95,7 +109,8 @@ export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
|
||||
setSheetOpen(false);
|
||||
}
|
||||
|
||||
const isOffline = connectivity === "offline";
|
||||
// An offline session cannot send, whatever the network says.
|
||||
const isOffline = connectivity === "offline" || !syncEnabled;
|
||||
const hasContent = isOffline || queue.own > 0 || hasRejected;
|
||||
|
||||
// Back to the trigger while it exists; otherwise to the band's message, or
|
||||
|
||||
@@ -2,20 +2,27 @@ import { beforeAll, 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 { discardQueueItem, listQueue, type QueueItem, type StoredPhoto } from "@/lib/offline/db";
|
||||
import {
|
||||
discardQueueItem,
|
||||
listQueue,
|
||||
retryBulkWithout,
|
||||
type QueueItem,
|
||||
type StoredPhoto,
|
||||
} from "@/lib/offline/db";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
|
||||
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
||||
import { PendingQueueSheet } from "./pending-queue-sheet";
|
||||
|
||||
vi.mock("@/lib/offline/db", () => ({
|
||||
listQueue: vi.fn(),
|
||||
discardQueueItem: vi.fn(),
|
||||
retryBulkWithout: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/photos/store")>()),
|
||||
listPendingPhotos: vi.fn(),
|
||||
removePhoto: vi.fn(),
|
||||
discardReadyPhoto: vi.fn(),
|
||||
resetPhotoError: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -85,14 +92,67 @@ describe("PendingQueueSheet", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]);
|
||||
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(removePhoto).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(syncPendingEntries)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
const REJECTED_BULK: QueueItem = {
|
||||
store: "pending-bulk",
|
||||
id: 9,
|
||||
label: "Behandlung für mehrere Völker",
|
||||
queuedAt: "2026-10-14T08:00:00.000Z",
|
||||
lastError: {
|
||||
status: 400,
|
||||
message: "Einige Völker sind nicht (mehr) erfassbar",
|
||||
at: "2026-10-14T09:00:00.000Z",
|
||||
},
|
||||
resendWithout: { colonyIds: ["c-3"], codes: ["V-3"] },
|
||||
};
|
||||
|
||||
it("„Ohne V-3 erneut senden“ resends the remaining ids with the same key", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([REJECTED_BULK]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
await userEvent.click(await within(sheet).findByRole("button", { name: /^Ohne V-3 erneut senden/ }));
|
||||
|
||||
// The entry keeps its key; only the refused colony leaves, then a run sends it.
|
||||
await waitFor(() => expect(retryBulkWithout).toHaveBeenCalledWith(9, ["c-3"]));
|
||||
await waitFor(() =>
|
||||
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
|
||||
);
|
||||
expect(vi.mocked(retryBulkWithout).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("names the button generally when a refused colony is not known by code", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([
|
||||
{ ...REJECTED_BULK, resendWithout: { colonyIds: ["c-3", "c-4"], codes: ["V-3"] } },
|
||||
]);
|
||||
renderSheet();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /^Ohne die abgelehnten Völker erneut senden/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no resend for a waiting bulk entry or a single rejected entry", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([
|
||||
{ ...REJECTED_BULK, lastError: undefined, resendWithout: undefined },
|
||||
REJECTED,
|
||||
]);
|
||||
renderSheet();
|
||||
await screen.findAllByText("Behandlung für mehrere Völker");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /erneut senden/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows „+ 2 Fotos“ at a queued inspection and discards them with it", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([{ ...REJECTED, photoCount: 2 }]);
|
||||
renderSheet();
|
||||
@@ -126,7 +186,10 @@ describe("PendingQueueSheet", () => {
|
||||
|
||||
await userEvent.click(retry[0]);
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
// It changed what is to be sent: a sync already running gets one more run.
|
||||
await waitFor(() =>
|
||||
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
|
||||
);
|
||||
expect(resetPhotoError).toHaveBeenCalledWith("photo-1");
|
||||
expect(vi.mocked(resetPhotoError).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
|
||||
@@ -144,7 +207,7 @@ describe("PendingQueueSheet", () => {
|
||||
const confirm = await screen.findByRole("dialog", { name: `${name} verwerfen?` });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
await waitFor(() => expect(removePhoto).toHaveBeenCalledWith("photo-1"));
|
||||
await waitFor(() => expect(discardReadyPhoto).toHaveBeenCalledWith("photo-1"));
|
||||
expect(within(sheet).queryByText("Von dir wartet nichts mehr.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@ import { NavSheet } from "@/components/nav/nav-sheet";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { discardQueueItem, listQueue, type QueueError, type QueueItem } from "@/lib/offline/db";
|
||||
import {
|
||||
discardQueueItem,
|
||||
listQueue,
|
||||
retryBulkWithout,
|
||||
type QueueError,
|
||||
type QueueItem,
|
||||
} from "@/lib/offline/db";
|
||||
import { entriesSynced, foreignEntriesWaiting, photosAttached } from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
|
||||
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
||||
|
||||
interface PendingQueueSheetProps {
|
||||
userId: string;
|
||||
@@ -80,6 +86,33 @@ function QueueStatus({ lastError }: { lastError?: QueueError }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
|
||||
function ResendWithoutButton({
|
||||
resend,
|
||||
disabled,
|
||||
onResend,
|
||||
}: {
|
||||
resend: NonNullable<QueueItem["resendWithout"]>;
|
||||
disabled: boolean;
|
||||
onResend: () => void;
|
||||
}) {
|
||||
const isNamed = resend.codes.length === resend.colonyIds.length;
|
||||
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="self-start"
|
||||
disabled={disabled}
|
||||
onClick={onResend}
|
||||
>
|
||||
<RotateCw aria-hidden />
|
||||
Ohne {without} erneut senden
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What this device still has to send for the signed-in user: each entry with
|
||||
* its state, a rejected one with the server's reason, and a way to discard it.
|
||||
@@ -120,7 +153,7 @@ export function PendingQueueSheet({
|
||||
});
|
||||
|
||||
const discardPhoto = useMutation({
|
||||
mutationFn: (photoId: string) => removePhoto(photoId),
|
||||
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
|
||||
networkMode: "always",
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
@@ -133,7 +166,8 @@ export function PendingQueueSheet({
|
||||
const retryPhoto = useMutation({
|
||||
mutationFn: async (photoId: string) => {
|
||||
await resetPhotoError(photoId);
|
||||
return syncPendingEntries(userId);
|
||||
// A sync already running may have passed this photo: it runs once more.
|
||||
return syncPendingEntries(userId, { afterChange: true });
|
||||
},
|
||||
networkMode: "always",
|
||||
onSuccess: (result) => {
|
||||
@@ -148,6 +182,26 @@ export function PendingQueueSheet({
|
||||
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
|
||||
});
|
||||
|
||||
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
|
||||
// key, rejection cleared), then a normal run sends the rest.
|
||||
const resendBulk = useMutation({
|
||||
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
|
||||
await retryBulkWithout(id, colonyIds);
|
||||
// A sync already running may have passed this entry: it runs once more.
|
||||
return syncPendingEntries(userId, { afterChange: true });
|
||||
},
|
||||
networkMode: "always",
|
||||
onSuccess: (result) => {
|
||||
if (result.synced > 0) {
|
||||
toast.success(entriesSynced(result.synced));
|
||||
void queryClient.invalidateQueries();
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
},
|
||||
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
||||
});
|
||||
|
||||
const names = entryNames(items);
|
||||
const photoNames = entryNames(
|
||||
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
|
||||
@@ -197,6 +251,18 @@ export function PendingQueueSheet({
|
||||
/>
|
||||
</div>
|
||||
<QueueStatus lastError={item.lastError} />
|
||||
{item.resendWithout && typeof item.id === "number" ? (
|
||||
<ResendWithoutButton
|
||||
resend={item.resendWithout}
|
||||
disabled={resendBulk.isPending}
|
||||
onResend={() =>
|
||||
resendBulk.mutate({
|
||||
id: item.id as number,
|
||||
colonyIds: item.resendWithout?.colonyIds ?? [],
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
{photos.map((photo, index) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeAll, 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 { addDraftPhoto, listSessionPhotos } from "@/lib/photos/store";
|
||||
import { addDraftPhoto, attachSessionToQueuedInspection, listSessionPhotos } from "@/lib/photos/store";
|
||||
import { PhotoMetadataError, preparePhoto } from "@/lib/photos/reencode";
|
||||
import { PhotoCaptureField } from "./photo-capture-field";
|
||||
|
||||
@@ -72,6 +72,16 @@ describe("PhotoCaptureField", () => {
|
||||
expect(screen.getByRole("button", { name: "Foto aufnehmen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps its announcement region present while it is empty", () => {
|
||||
renderField();
|
||||
|
||||
// Hidden while empty, it would swallow the first message (jsdom applies no CSS).
|
||||
const live = screen.getByRole("status");
|
||||
expect(live).toHaveAttribute("aria-live", "polite");
|
||||
expect(live).toBeEmptyDOMElement();
|
||||
expect([...live.classList].some((name) => /(^|:)hidden$/.test(name))).toBe(false);
|
||||
});
|
||||
|
||||
it("is hidden without documents:upload", () => {
|
||||
permissions = new Set();
|
||||
const { container } = renderField();
|
||||
@@ -129,6 +139,19 @@ describe("PhotoCaptureField", () => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers „Entfernen“ only for photos that are not saved yet", async () => {
|
||||
await storePhotos(1);
|
||||
await attachSessionToQueuedInspection(sessionId, "inspection-key");
|
||||
await storePhotos(1);
|
||||
renderField();
|
||||
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
|
||||
|
||||
// Foto 1 already belongs to an inspection in the queue; only Foto 2 is the form's own.
|
||||
expect(screen.queryByRole("button", { name: "Entfernen: Foto 1" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Entfernen: Foto 2" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Gespeichert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps nothing when the location data could not be removed, and says so", async () => {
|
||||
vi.mocked(preparePhoto).mockRejectedValue(new PhotoMetadataError());
|
||||
const { container } = renderField();
|
||||
|
||||
@@ -177,17 +177,24 @@ function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProp
|
||||
alt={name}
|
||||
className="aspect-square w-full rounded-lg border border-border/60 object-cover"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
triggerLabel="Entfernen"
|
||||
triggerAriaLabel={`Entfernen: ${name}`}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title={`${name} entfernen?`}
|
||||
description="Das Foto wird von diesem Gerät gelöscht."
|
||||
confirmLabel="Entfernen"
|
||||
onConfirm={() => remove.mutate(photo.id)}
|
||||
isPending={remove.isPending}
|
||||
/>
|
||||
{/* A photo of a saved or queued inspection is never removed from a form. */}
|
||||
{photo.state === "draft" ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Entfernen"
|
||||
triggerAriaLabel={`Entfernen: ${name}`}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title={`${name} entfernen?`}
|
||||
description="Das Foto wird von diesem Gerät gelöscht."
|
||||
confirmLabel="Entfernen"
|
||||
onConfirm={() => remove.mutate(photo.id)}
|
||||
isPending={remove.isPending}
|
||||
/>
|
||||
) : (
|
||||
<p className="flex min-h-11 items-center justify-center text-sm text-muted-foreground">
|
||||
Gespeichert
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -214,11 +221,12 @@ function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProp
|
||||
aria-hidden
|
||||
onChange={handleFile}
|
||||
/>
|
||||
{/* Present while empty (only visually hidden), or its first message is not announced. */}
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"text-sm empty:hidden",
|
||||
"text-sm empty:sr-only",
|
||||
notice.isError ? "font-medium text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { Field } from "@/components/form/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { VarroaRateLight } from "@/components/varroa/varroa-rate-light";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { mitesPerDay } from "@/lib/varroa/rate";
|
||||
|
||||
interface BulkMiteCountsProps {
|
||||
/** The chosen colonies, in the order they are listed. */
|
||||
colonies: readonly { id: string; code: string }[];
|
||||
/** The typed count of each colony, by id. */
|
||||
counts: Readonly<Record<string, string>>;
|
||||
onCountChange: (colonyId: string, count: string) => void;
|
||||
/** Shared by all colonies; with a natural mite fall it gives each one its rate. */
|
||||
durationMinutes: string;
|
||||
showsRate: boolean;
|
||||
threshold: number | null;
|
||||
errors: Readonly<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One mite count per colony - there is no shared count - and, for a natural
|
||||
* mite fall, the rate with its traffic light right under each count.
|
||||
*/
|
||||
export function BulkMiteCounts({
|
||||
colonies,
|
||||
counts,
|
||||
onCountChange,
|
||||
durationMinutes,
|
||||
showsRate,
|
||||
threshold,
|
||||
errors,
|
||||
}: BulkMiteCountsProps) {
|
||||
const missing = colonies.filter(({ id }) => (counts[id] ?? "").trim() === "");
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p role="status" aria-live="polite" className="text-sm font-medium">
|
||||
{missing.length > 0
|
||||
? `Noch ohne Milbenzahl: ${missing.map(({ code }) => code).join(", ")}`
|
||||
: colonies.length > 0
|
||||
? "Jedes Volk hat eine Milbenzahl."
|
||||
: ""}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-4">
|
||||
{colonies.map(({ id, code }) => {
|
||||
const count = counts[id] ?? "";
|
||||
return (
|
||||
<li key={id}>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={code}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Field label={`Milbenzahl ${code}`} required error={errors[id]}>
|
||||
{({ id: inputId, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={inputId}
|
||||
data-field={`miteCount-${id}`}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={count}
|
||||
onChange={(event) =>
|
||||
onCountChange(id, event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{showsRate ? (
|
||||
<VarroaRateLight
|
||||
rate={mitesPerDay(
|
||||
emptyToNull(count, Number),
|
||||
emptyToNull(durationMinutes, Number),
|
||||
)}
|
||||
threshold={threshold}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { fetchColonyOverview } from "./field-api";
|
||||
import { listPacks } from "./offline-pack";
|
||||
import { offlinePack, packColony, storedPack } from "./offline-pack.fixtures";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
vi.mock("./offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
const PACK = offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
colonies: [packColony("colony-1", "A1"), packColony("colony-2", "A2")],
|
||||
});
|
||||
|
||||
describe("fetchColonyOverview", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([storedPack(PACK)]);
|
||||
});
|
||||
|
||||
it("asks the server", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(PACK.overview);
|
||||
|
||||
expect(await fetchColonyOverview("apiary-1", "user-1")).toEqual(PACK.overview);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-1/colony-overview");
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers from the loaded pack without network", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
const overview = await fetchColonyOverview("apiary-1", "user-1");
|
||||
|
||||
expect(overview.rows.map((row) => row.code)).toEqual(["A1", "A2"]);
|
||||
expect(listPacks).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("fails without network when this apiary was not loaded", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-2", "user-1")).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
it("never replaces a refusal with pack data", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new ApiError(404, "Apiary not found"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-1", "user-1")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only asks the server when nobody is signed in", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(fetchColonyOverview("apiary-1", null)).rejects.toBeInstanceOf(TypeError);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,26 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
import type { ColonyOverview } from "./field-types";
|
||||
|
||||
export function fetchColonyOverview(apiaryId: string): Promise<ColonyOverview> {
|
||||
return apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
|
||||
/**
|
||||
* The apiary's colonies with their traffic-light inputs. For a signed-in
|
||||
* user without network it answers from the apiary's offline pack, if loaded
|
||||
* (same shape: the pack carries the overview as it was when loaded).
|
||||
*/
|
||||
export function fetchColonyOverview(
|
||||
apiaryId: string,
|
||||
ownerUserId: string | null = null,
|
||||
): Promise<ColonyOverview> {
|
||||
const fromServer = () =>
|
||||
apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
|
||||
if (!ownerUserId) {
|
||||
return fromServer();
|
||||
}
|
||||
return withPackFallback(
|
||||
fromServer,
|
||||
(packs) => packs.find((stored) => stored.apiaryId === apiaryId)?.pack.overview ?? null,
|
||||
ownerUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Query key of the apiary page's colony overview. */
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Test data for offline packs - shared by the specs, never imported by app code.
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
import type { ColonyOverviewRow, OfflinePackV1 } from "./field-types";
|
||||
|
||||
export const MINUTE_MS = 60 * 1000;
|
||||
export const HOUR_MS = 60 * MINUTE_MS;
|
||||
export const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
type PackColony = OfflinePackV1["colonies"][number];
|
||||
type PackInspection = NonNullable<OfflinePackV1["inspections"]>[number];
|
||||
type PackTask = NonNullable<OfflinePackV1["tasks"]>[number];
|
||||
type PackQueen = NonNullable<OfflinePackV1["queens"]>[number];
|
||||
|
||||
export function packColony(id: string, code: string, overrides: Partial<PackColony> = {}): PackColony {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
apiaryId: "apiary-1",
|
||||
status: "ACTIVE",
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
hiveType: "Zander",
|
||||
strength: 8,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "GOOD",
|
||||
healthStatus: "GOOD",
|
||||
currentQueenId: null,
|
||||
notes: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function overviewRow(colony: PackColony, overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
|
||||
return {
|
||||
id: colony.id,
|
||||
code: colony.code,
|
||||
status: colony.status,
|
||||
strength: colony.strength,
|
||||
broodStatus: colony.broodStatus,
|
||||
feedStatus: colony.feedStatus,
|
||||
healthStatus: colony.healthStatus,
|
||||
lastInspectionDate: new Date().toISOString(),
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packInspection(
|
||||
id: string,
|
||||
colonyId: string,
|
||||
date: string,
|
||||
overrides: Partial<PackInspection> = {},
|
||||
): PackInspection {
|
||||
return {
|
||||
id,
|
||||
colonyId,
|
||||
date,
|
||||
weather: null,
|
||||
temperature: null,
|
||||
colonyStrength: null,
|
||||
broodAmount: null,
|
||||
broodFrames: null,
|
||||
broodPattern: null,
|
||||
openBroodPresent: null,
|
||||
cappedBroodPresent: null,
|
||||
eggsPresent: null,
|
||||
queenSeen: null,
|
||||
queenCellsPresent: null,
|
||||
swarmMood: null,
|
||||
gentleness: null,
|
||||
combBehavior: null,
|
||||
feedStatus: null,
|
||||
honeySuperStatus: null,
|
||||
healthIssues: null,
|
||||
varroaIssues: null,
|
||||
measures: null,
|
||||
notes: null,
|
||||
createdAt: date,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packTask(id: string, title: string, overrides: Partial<PackTask> = {}): PackTask {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description: null,
|
||||
dueDate: null,
|
||||
priority: "NORMAL",
|
||||
status: "OPEN",
|
||||
relatedType: "COLONY",
|
||||
relatedId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function packQueen(id: string, code: string, overrides: Partial<PackQueen> = {}): PackQueen {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
hatchYear: 2025,
|
||||
breed: "Carnica",
|
||||
markingColor: "Rot",
|
||||
markingNumber: "12",
|
||||
status: "ACTIVE",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface PackOptions {
|
||||
apiaryId?: string;
|
||||
apiaryName?: string;
|
||||
colonies?: PackColony[];
|
||||
queens?: OfflinePackV1["queens"];
|
||||
inspections?: OfflinePackV1["inspections"];
|
||||
tasks?: OfflinePackV1["tasks"];
|
||||
inspectionsVisible?: boolean;
|
||||
}
|
||||
|
||||
/** A pack as the server sends it: colonies, their overview rows and the sections. */
|
||||
export function offlinePack({
|
||||
apiaryId = "apiary-1",
|
||||
apiaryName = "Obstwiese",
|
||||
colonies = [packColony("colony-1", "A1", { apiaryId })],
|
||||
queens = [],
|
||||
inspections = [],
|
||||
tasks = [],
|
||||
inspectionsVisible = true,
|
||||
}: PackOptions = {}): OfflinePackV1 {
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: "2026-10-13T08:00:00.000Z",
|
||||
apiary: { id: apiaryId, name: apiaryName, location: "Hinterm Hof", status: "ACTIVE" },
|
||||
overview: {
|
||||
apiaryId,
|
||||
inspectionsVisible,
|
||||
rows: colonies.map((colony) => overviewRow(colony)),
|
||||
},
|
||||
colonies,
|
||||
queens,
|
||||
inspections,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export function storedPack(
|
||||
pack: OfflinePackV1,
|
||||
{ ownerUserId = "user-1", savedAt = new Date().toISOString() } = {},
|
||||
): StoredPack {
|
||||
return { apiaryId: pack.apiary.id, ownerUserId, savedAt, pack };
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import {
|
||||
PACK_AUTO_REFRESH_AFTER_MS,
|
||||
PACK_MAX_AGE_MS,
|
||||
PACK_STALE_MS,
|
||||
deleteAllPacks,
|
||||
findPackForColony,
|
||||
getPack,
|
||||
isPackStale,
|
||||
listPacks,
|
||||
loadPack,
|
||||
packAgeText,
|
||||
purgeOldPacks,
|
||||
refreshPacksAfterSync,
|
||||
} from "./offline-pack";
|
||||
import {
|
||||
DAY_MS,
|
||||
HOUR_MS,
|
||||
MINUTE_MS,
|
||||
offlinePack,
|
||||
packColony,
|
||||
storedPack,
|
||||
} from "./offline-pack.fixtures";
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const NOW = Date.parse("2026-10-13T12:00:00.000Z");
|
||||
|
||||
function savedAgo(ms: number): string {
|
||||
return new Date(NOW - ms).toISOString();
|
||||
}
|
||||
|
||||
async function putPacks(...packs: StoredPack[]): Promise<void> {
|
||||
const db = await getDb();
|
||||
await Promise.all(packs.map((pack) => db.put("offline-packs", pack)));
|
||||
}
|
||||
|
||||
async function storedApiaryIds(): Promise<string[]> {
|
||||
const db = await getDb();
|
||||
return (await db.getAllKeys("offline-packs")).sort();
|
||||
}
|
||||
|
||||
describe("offline packs", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
await deleteAllPacks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stores the pack with owner and time", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
const pack = offlinePack({ apiaryId: "apiary-1" });
|
||||
vi.mocked(apiFetch).mockResolvedValue(pack);
|
||||
|
||||
const stored = await loadPack("apiary-1", "user-1");
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-1/offline-pack");
|
||||
expect(stored).toEqual({
|
||||
apiaryId: "apiary-1",
|
||||
ownerUserId: "user-1",
|
||||
savedAt: new Date(NOW).toISOString(),
|
||||
pack,
|
||||
});
|
||||
expect(await getPack("apiary-1")).toEqual(stored);
|
||||
});
|
||||
|
||||
it("never stores coordinates or an address, even if the server sent them", async () => {
|
||||
const pack = offlinePack({ apiaryId: "apiary-1" });
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
...pack,
|
||||
apiary: { ...pack.apiary, latitude: 52.1, longitude: 13.4, address: "Feldweg 3", notes: "Schlüssel unterm Stein" },
|
||||
});
|
||||
|
||||
await loadPack("apiary-1", "user-1");
|
||||
|
||||
const kept = await getPack("apiary-1");
|
||||
expect(Object.keys(kept?.pack.apiary ?? {}).sort()).toEqual(["id", "location", "name", "status"]);
|
||||
});
|
||||
|
||||
it("refuses a pack it does not understand instead of storing it", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ...offlinePack(), version: 2 });
|
||||
|
||||
await expect(loadPack("apiary-1", "user-1")).rejects.toThrow();
|
||||
expect(await getPack("apiary-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("encodes the apiary id in the request", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "a/b" }));
|
||||
|
||||
await loadPack("a/b", "user-1");
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/a%2Fb/offline-pack");
|
||||
});
|
||||
|
||||
it("findPackForColony", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1", colonies: [packColony("colony-1", "A1")] })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("colony-2", "B1")] })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-3", colonies: [packColony("colony-3", "C1")] }), {
|
||||
ownerUserId: "user-2",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await findPackForColony("colony-2", "user-1"))?.apiaryId).toBe("apiary-2");
|
||||
// Another account's pack is never handed out.
|
||||
expect(await findPackForColony("colony-3", "user-1")).toBeNull();
|
||||
expect(await findPackForColony("colony-unknown", "user-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("findPackForColony prefers the newest pack when a colony moved between loaded apiaries", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old", colonies: [packColony("colony-1", "A1")] }), {
|
||||
savedAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(),
|
||||
}),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-new", colonies: [packColony("colony-1", "A1")] }), {
|
||||
savedAt: new Date(Date.now() - HOUR_MS).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await findPackForColony("colony-1", "user-1"))?.apiaryId).toBe("apiary-new");
|
||||
});
|
||||
|
||||
it("lists only the owner's packs", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
|
||||
expect((await listPacks("user-1")).map((pack) => pack.apiaryId)).toEqual(["apiary-1"]);
|
||||
});
|
||||
|
||||
it("never hands out a pack older than 14 days, even before it is purged", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), {
|
||||
savedAt: new Date(Date.now() - PACK_MAX_AGE_MS - MINUTE_MS).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await listPacks("user-1")).toEqual([]);
|
||||
expect(await getPack("apiary-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("purges packs older than 14 days", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(15 * DAY_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-edge" }), { savedAt: savedAgo(PACK_MAX_AGE_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(13 * DAY_MS) }),
|
||||
);
|
||||
|
||||
const purged = await purgeOldPacks(NOW);
|
||||
|
||||
expect(purged).toBe(1);
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-edge", "apiary-young"]);
|
||||
});
|
||||
|
||||
it("stale after 24 h", () => {
|
||||
const pack = offlinePack();
|
||||
|
||||
expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS - 1) }), NOW)).toBe(false);
|
||||
expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS) }), NOW)).toBe(true);
|
||||
expect(isPackStale(storedPack(pack, { savedAt: "kaputt" }), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes every pack of every account", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" })),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }),
|
||||
);
|
||||
|
||||
await deleteAllPacks();
|
||||
|
||||
expect(await storedApiaryIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("says how long ago a pack was loaded", () => {
|
||||
const at = (ms: number) => packAgeText(savedAgo(ms), NOW);
|
||||
|
||||
expect(at(20 * 1000)).toBe("vor weniger als einer Minute");
|
||||
expect(at(MINUTE_MS)).toBe("vor 1 Minute");
|
||||
expect(at(5 * MINUTE_MS)).toBe("vor 5 Minuten");
|
||||
expect(at(HOUR_MS)).toBe("vor 1 Stunde");
|
||||
expect(at(3 * HOUR_MS + 10 * MINUTE_MS)).toBe("vor 3 Stunden");
|
||||
expect(at(DAY_MS)).toBe("vor 1 Tag");
|
||||
expect(at(2 * DAY_MS + HOUR_MS)).toBe("vor 2 Tagen");
|
||||
});
|
||||
|
||||
describe("refreshPacksAfterSync", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
it("quietly reloads the owner's packs older than 1 h and leaves younger ones alone", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-old" }), {
|
||||
savedAt: savedAgo(PACK_AUTO_REFRESH_AFTER_MS + MINUTE_MS),
|
||||
}),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(30 * MINUTE_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-foreign" }), {
|
||||
ownerUserId: "user-2",
|
||||
savedAt: savedAgo(5 * HOUR_MS),
|
||||
}),
|
||||
);
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-old", apiaryName: "Neu" }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-old/offline-pack");
|
||||
const refreshed = await getPack("apiary-old");
|
||||
expect(refreshed?.pack.apiary.name).toBe("Neu");
|
||||
expect(refreshed?.savedAt).toBe(new Date(NOW).toISOString());
|
||||
});
|
||||
|
||||
it("after a rejection reloads a 10-min-old pack holding the colony, not a 2-min-old one", async () => {
|
||||
await putPacks(
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-ten", colonies: [packColony("colony-3", "V-03")] }),
|
||||
{ savedAt: savedAgo(10 * MINUTE_MS) },
|
||||
),
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-two", colonies: [packColony("colony-3", "V-03")] }),
|
||||
{ savedAt: savedAgo(2 * MINUTE_MS) },
|
||||
),
|
||||
storedPack(
|
||||
offlinePack({ apiaryId: "apiary-other", colonies: [packColony("colony-9", "X-09")] }),
|
||||
{ savedAt: savedAgo(10 * MINUTE_MS) },
|
||||
),
|
||||
);
|
||||
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-ten", colonies: [] }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW, undefined, {
|
||||
force: true,
|
||||
colonyIds: ["colony-3"],
|
||||
});
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-ten/offline-pack");
|
||||
// The colony ended on the server: the reloaded pack no longer offers it.
|
||||
expect((await getPack("apiary-ten"))?.pack.colonies).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a pack the user may no longer read or whose apiary is gone", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-gone" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-forbidden" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-broken" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
vi.mocked(apiFetch).mockImplementation(async (path: string) => {
|
||||
if (path.includes("apiary-gone")) throw new ApiError(404, "Apiary not found");
|
||||
if (path.includes("apiary-forbidden")) throw new ApiError(403, "Forbidden");
|
||||
throw new ApiError(500, "Internal server error");
|
||||
});
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
// A server failure keeps the pack: it is still the best the device knows.
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-broken"]);
|
||||
});
|
||||
|
||||
it("stops at the first network error and never throws", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
await expect(refreshPacksAfterSync("user-1", NOW)).resolves.toBeUndefined();
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(await storedApiaryIds()).toEqual(["apiary-1", "apiary-2"]);
|
||||
});
|
||||
|
||||
it("stops once the sync run is cancelled", async () => {
|
||||
await putPacks(
|
||||
storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }),
|
||||
);
|
||||
let cancelled = false;
|
||||
vi.mocked(apiFetch).mockImplementation(async (path: string) => {
|
||||
cancelled = true;
|
||||
return offlinePack({ apiaryId: path.split("/")[2] });
|
||||
});
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW, () => cancelled);
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("purges packs older than 14 days on the way", async () => {
|
||||
await putPacks(storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(20 * DAY_MS) }));
|
||||
|
||||
await refreshPacksAfterSync("user-1", NOW);
|
||||
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
expect(await storedApiaryIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { getConnectivity } from "@/lib/offline/connectivity";
|
||||
import { getDb, type StoredPack } from "@/lib/offline/db";
|
||||
import type { OfflinePackV1 } from "./field-types";
|
||||
|
||||
/** From this age on a pack is marked as old: "bei Netz neu laden". */
|
||||
export const PACK_STALE_MS = 24 * 60 * 60 * 1000;
|
||||
/** Older packs are deleted - and never shown in the meantime. */
|
||||
export const PACK_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
/** After a sync, packs older than this are reloaded quietly. */
|
||||
export const PACK_AUTO_REFRESH_AFTER_MS = 60 * 60 * 1000;
|
||||
/** After a rejection, a pack listing the colony is reloaded from this age on. */
|
||||
export const PACK_FORCED_REFRESH_AFTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Prefix of every query that reads packs (this device's storage only). */
|
||||
export const OFFLINE_PACKS_QUERY_KEY = ["offline-packs"] as const;
|
||||
|
||||
export type PackColony = OfflinePackV1["colonies"][number];
|
||||
export type PackInspection = NonNullable<OfflinePackV1["inspections"]>[number];
|
||||
export type PackTask = NonNullable<OfflinePackV1["tasks"]>[number];
|
||||
export type PackQueen = NonNullable<OfflinePackV1["queens"]>[number];
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
function ageOf(pack: StoredPack, now: number): number {
|
||||
// An unreadable time counts as infinitely old: refreshed, marked, purged.
|
||||
const age = now - Date.parse(pack.savedAt);
|
||||
return Number.isNaN(age) ? Number.POSITIVE_INFINITY : age;
|
||||
}
|
||||
|
||||
function isExpired(pack: StoredPack, now: number): boolean {
|
||||
return ageOf(pack, now) > PACK_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
export function isPackStale(pack: StoredPack, now: number): boolean {
|
||||
return ageOf(pack, now) >= PACK_STALE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly the apiary fields the offline mode shows. Apiary locations are
|
||||
* theft-sensitive: should the server ever send coordinates, an address or
|
||||
* notes in a pack, they never reach the stored pack (IndexedDB). The service
|
||||
* worker separately never keeps the apiary answers that carry them
|
||||
* (/apiaries, /apiaries/:id - see public/sw-routes.js).
|
||||
*/
|
||||
function withoutApiaryLocation(pack: OfflinePackV1): OfflinePackV1 {
|
||||
const { id, name, location, status } = pack.apiary;
|
||||
return { ...pack, apiary: { id, name, location, status } };
|
||||
}
|
||||
|
||||
function isPackFor(value: unknown, apiaryId: string): value is OfflinePackV1 {
|
||||
const pack = value as Partial<OfflinePackV1> | null;
|
||||
return pack?.version === 1 && pack.apiary?.id === apiaryId && Array.isArray(pack.colonies);
|
||||
}
|
||||
|
||||
export async function fetchOfflinePack(apiaryId: string): Promise<OfflinePackV1> {
|
||||
const pack: unknown = await apiFetch<unknown>(
|
||||
`/apiaries/${encodeURIComponent(apiaryId)}/offline-pack`,
|
||||
);
|
||||
if (!isPackFor(pack, apiaryId)) {
|
||||
throw new Error("Unbekanntes Offline-Paket – bitte die App aktualisieren.");
|
||||
}
|
||||
return pack;
|
||||
}
|
||||
|
||||
/** Fetches the apiary's pack and keeps it on this device for `ownerUserId`. */
|
||||
export async function loadPack(apiaryId: string, ownerUserId: string): Promise<StoredPack> {
|
||||
const pack = withoutApiaryLocation(await fetchOfflinePack(apiaryId));
|
||||
const stored: StoredPack = {
|
||||
apiaryId,
|
||||
ownerUserId,
|
||||
savedAt: new Date().toISOString(),
|
||||
pack,
|
||||
};
|
||||
const db = await getDb();
|
||||
await db.put("offline-packs", stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** The apiary's pack, of whichever account loaded it; null when none or expired. */
|
||||
export async function getPack(apiaryId: string): Promise<StoredPack | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("offline-packs", apiaryId);
|
||||
return stored && !isExpired(stored, Date.now()) ? stored : null;
|
||||
}
|
||||
|
||||
/** The apiary's pack only if `ownerUserId` loaded it - another account's never counts. */
|
||||
export async function getOwnPack(apiaryId: string, ownerUserId: string): Promise<StoredPack | null> {
|
||||
const stored = await getPack(apiaryId);
|
||||
return stored?.ownerUserId === ownerUserId ? stored : null;
|
||||
}
|
||||
|
||||
/** Query key of getOwnPack (this device's storage, never the network). */
|
||||
export function ownPackQueryKey(ownerUserId: string | null, apiaryId: string) {
|
||||
return [...OFFLINE_PACKS_QUERY_KEY, ownerUserId, "apiary", apiaryId] as const;
|
||||
}
|
||||
|
||||
/** The packs `ownerUserId` loaded on this device, none expired. */
|
||||
export async function listPacks(ownerUserId: string): Promise<StoredPack[]> {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
return (await db.getAll("offline-packs")).filter(
|
||||
(stored) => stored.ownerUserId === ownerUserId && !isExpired(stored, now),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest of the given packs that lists the colony. A colony moved
|
||||
* between two loaded apiaries is where the later loaded pack says.
|
||||
*/
|
||||
export function packForColony(packs: readonly StoredPack[], colonyId: string): StoredPack | null {
|
||||
const holding = packs.filter((stored) =>
|
||||
stored.pack.colonies.some((colony) => colony.id === colonyId),
|
||||
);
|
||||
return [...holding].sort((a, b) => b.savedAt.localeCompare(a.savedAt))[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findPackForColony(
|
||||
colonyId: string,
|
||||
ownerUserId: string,
|
||||
): Promise<StoredPack | null> {
|
||||
return packForColony(await listPacks(ownerUserId), colonyId);
|
||||
}
|
||||
|
||||
export async function deleteAllPacks(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.clear("offline-packs");
|
||||
}
|
||||
|
||||
async function deletePack(apiaryId: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("offline-packs", apiaryId);
|
||||
}
|
||||
|
||||
/** Deletes packs older than 14 days; returns how many. */
|
||||
export async function purgeOldPacks(now: number): Promise<number> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("offline-packs", "readwrite");
|
||||
const expired = (await tx.store.getAll()).filter((stored) => isExpired(stored, now));
|
||||
await Promise.all(expired.map((stored) => tx.store.delete(stored.apiaryId)));
|
||||
await tx.done;
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
/** The server no longer lets this user see the apiary (or it is gone). */
|
||||
function isRevoked(error: unknown): boolean {
|
||||
return error instanceof ApiError && (error.status === 403 || error.status === 404);
|
||||
}
|
||||
|
||||
export interface PackRefreshOptions {
|
||||
/** The server rejected entries: reload the packs of their colonies now. */
|
||||
force: true;
|
||||
/** Colonies of the rejected entries. */
|
||||
colonyIds: readonly string[];
|
||||
}
|
||||
|
||||
function isDue(stored: StoredPack, now: number, options?: PackRefreshOptions): boolean {
|
||||
const age = ageOf(stored, now);
|
||||
if (age > PACK_AUTO_REFRESH_AFTER_MS) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
options?.force === true &&
|
||||
age > PACK_FORCED_REFRESH_AFTER_MS &&
|
||||
stored.pack.colonies.some((colony) => options.colonyIds.includes(colony.id))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a sync run: reloads the user's packs older than 1 h, so what the
|
||||
* server has just accepted or rejected (a colony ended meanwhile) shows up
|
||||
* offline too. With `force`, the packs listing a rejected entry's colony are
|
||||
* reloaded from 5 min of age on. Quietly - it never throws. A pack the user
|
||||
* may no longer read is deleted; at the first network error it stops (try
|
||||
* again next run).
|
||||
*/
|
||||
export async function refreshPacksAfterSync(
|
||||
ownerUserId: string,
|
||||
now: number,
|
||||
isCancelled: () => boolean = () => false,
|
||||
options?: PackRefreshOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await purgeOldPacks(now);
|
||||
if (getConnectivity() === "offline") {
|
||||
return;
|
||||
}
|
||||
const due = (await listPacks(ownerUserId)).filter((stored) => isDue(stored, now, options));
|
||||
for (const stored of due) {
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadPack(stored.apiaryId, ownerUserId);
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return;
|
||||
}
|
||||
if (isRevoked(error)) {
|
||||
await deletePack(stored.apiaryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Housekeeping only: the next sync run tries again.
|
||||
}
|
||||
}
|
||||
|
||||
/** Living colonies of all given packs, each once (newest pack wins), by code. */
|
||||
export function livingColoniesFromPacks(packs: readonly StoredPack[]): PackColony[] {
|
||||
const newestFirst = [...packs].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||
const byId = new Map<string, PackColony>();
|
||||
for (const stored of newestFirst) {
|
||||
for (const colony of stored.pack.colonies) {
|
||||
if (!byId.has(colony.id) && isLivingColony(colony)) {
|
||||
byId.set(colony.id, colony);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) =>
|
||||
a.code.localeCompare(b.code, "de", { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colony's inspections in its pack (the last three); null when no pack
|
||||
* lists the colony or the user may not read inspections.
|
||||
*/
|
||||
export function inspectionsFromPacks(
|
||||
packs: readonly StoredPack[],
|
||||
colonyId: string,
|
||||
): PackInspection[] | null {
|
||||
const inspections = packForColony(packs, colonyId)?.pack.inspections ?? null;
|
||||
return inspections?.filter((inspection) => inspection.colonyId === colonyId) ?? null;
|
||||
}
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/** "vor 5 Minuten" - how long ago the pack was loaded. */
|
||||
export function packAgeText(savedAt: string, now: number): string {
|
||||
const age = Math.max(0, now - Date.parse(savedAt));
|
||||
if (age < MINUTE_MS) {
|
||||
return "vor weniger als einer Minute";
|
||||
}
|
||||
if (age < HOUR_MS) {
|
||||
return `vor ${plural(Math.floor(age / MINUTE_MS), "Minute", "Minuten")}`;
|
||||
}
|
||||
if (age < DAY_MS) {
|
||||
return `vor ${plural(Math.floor(age / HOUR_MS), "Stunde", "Stunden")}`;
|
||||
}
|
||||
return `vor ${plural(Math.floor(age / DAY_MS), "Tag", "Tagen")}`;
|
||||
}
|
||||
|
||||
/** The local calendar day a pack was loaded, as "13.10.2026". */
|
||||
export function packDayText(savedAt: string): string {
|
||||
const date = new Date(savedAt);
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${day}.${month}.${date.getFullYear()}`;
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { colonyOverviewKey, fetchColonyOverview } from "./field-api";
|
||||
|
||||
/** The apiary's living colonies with their traffic-light inputs. */
|
||||
/**
|
||||
* The apiary's living colonies with their traffic-light inputs - offline
|
||||
* from the loaded pack, so the apiary page and the stand round
|
||||
* (`stillInApiary`) keep working at the apiary.
|
||||
*/
|
||||
export function useColonyOverview(apiaryId: string, enabled = true) {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: colonyOverviewKey(apiaryId),
|
||||
queryFn: () => fetchColonyOverview(apiaryId),
|
||||
queryFn: () => fetchColonyOverview(apiaryId, userId),
|
||||
enabled,
|
||||
// Without network the answer comes from this device's storage.
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
} from "@/lib/http/fetch-with-timeout";
|
||||
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
|
||||
import { setAccessToken } from "./token-store";
|
||||
import { getAccessToken, setAccessToken, setRefreshedTokenHandler } from "./token-store";
|
||||
import { ApiError, apiFetch, apiFetchBlob, apiFetchFormData } from "./api-client";
|
||||
|
||||
vi.mock("../config/runtime-config", () => ({
|
||||
@@ -102,3 +102,67 @@ describe("api-client", () => {
|
||||
expect(error.body).toEqual({ message: "Nein", why: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("api-client token refresh", () => {
|
||||
beforeEach(() => {
|
||||
setAccessToken(null);
|
||||
reportRequestOutcome("reached-server");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setRefreshedTokenHandler(null);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubServer(refreshBody: unknown) {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/auth/refresh")) return jsonResponse(200, refreshBody);
|
||||
const retried = fetchMock.mock.calls.filter(([called]) => called === url).length > 1;
|
||||
return retried ? jsonResponse(200, { ok: true }) : jsonResponse(401, {});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
it("retries with the new token once the handler accepts it", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(true);
|
||||
setRefreshedTokenHandler(handler);
|
||||
stubServer({ accessToken: "fresh" });
|
||||
|
||||
await expect(apiFetch("/things")).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith("fresh");
|
||||
});
|
||||
|
||||
it("does not retry - and keeps no token - when the handler rejects it (another account)", async () => {
|
||||
setRefreshedTokenHandler(vi.fn().mockResolvedValue(false));
|
||||
const fetchMock = stubServer({ accessToken: "other-users-token" });
|
||||
|
||||
await expect(apiFetch("/things")).rejects.toMatchObject({ status: 401 });
|
||||
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/things"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("takes the token as it is without a handler", async () => {
|
||||
stubServer({ accessToken: "fresh" });
|
||||
|
||||
await expect(apiFetch("/things")).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(getAccessToken()).toBe("fresh");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["blobs", () => apiFetchBlob("/export", "x.csv")],
|
||||
["form data", () => apiFetchFormData("/upload", new FormData())],
|
||||
])("also asks the handler before retrying %s", async (_name, call) => {
|
||||
const handler = vi.fn().mockResolvedValue(false);
|
||||
setRefreshedTokenHandler(handler);
|
||||
stubServer({ accessToken: "other" });
|
||||
|
||||
await expect(call()).rejects.toBeInstanceOf(ApiError);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith("other");
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAccessToken, setAccessToken } from './token-store';
|
||||
import { acceptRefreshedToken, getAccessToken } from './token-store';
|
||||
import { refreshRequest } from './api';
|
||||
import { getApiUrl } from '../config/runtime-config';
|
||||
import {
|
||||
@@ -63,8 +63,7 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
@@ -114,8 +113,7 @@ export async function apiFetchFormData<T>(
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetchFormData(path, formData, options);
|
||||
}
|
||||
}
|
||||
@@ -132,8 +130,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetch(path, init);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
|
||||
isNetworkError,
|
||||
} from "@/lib/http/fetch-with-timeout";
|
||||
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
|
||||
import {
|
||||
@@ -42,3 +43,41 @@ describe("auth api timeouts", () => {
|
||||
await expectTimeoutAt(() => getCurrentUserRequest("tok"), DEFAULT_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshRequest outcome", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function answerWith(status: number, body: unknown = {}) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })),
|
||||
);
|
||||
}
|
||||
|
||||
it("returns the access token when the server accepts the session", async () => {
|
||||
answerWith(200, { accessToken: "token-1" });
|
||||
|
||||
await expect(refreshRequest()).resolves.toEqual({ accessToken: "token-1" });
|
||||
});
|
||||
|
||||
it.each([401, 403])("says the session is over for %i", async (status) => {
|
||||
answerWith(status);
|
||||
|
||||
await expect(refreshRequest()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([429, 500, 502, 503, 504])(
|
||||
"does not end the session for %i - the server was just unavailable",
|
||||
async (status) => {
|
||||
answerWith(status);
|
||||
|
||||
const failure = await refreshRequest().catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
// Treated like a lost connection by everything that retries or queues.
|
||||
expect(isNetworkError(failure)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -69,9 +69,15 @@ async function performRefresh(): Promise<AccessTokenResponse | null> {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Only 401/403 say the session is over. Anything else (429, 5xx from the
|
||||
// server or a proxy) is a server that cannot answer right now: a TypeError,
|
||||
// so it counts as a lost connection and never as an ended session.
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new TypeError(`Refresh unavailable (HTTP ${response.status}).`);
|
||||
}
|
||||
return response.json() as Promise<AccessTokenResponse>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "sonner";
|
||||
import { getDb, queueInspection, summarizeQueue } from "@/lib/offline/db";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import { cancelSync, resumeSync, syncPendingEntries } from "@/lib/offline/sync";
|
||||
import {
|
||||
getCurrentUserRequest,
|
||||
loginRequest,
|
||||
logoutRequest,
|
||||
refreshRequest,
|
||||
verifyMfaRequest,
|
||||
} from "./api";
|
||||
import { AuthProvider, useAuth, type AuthUser } from "./auth-context";
|
||||
import {
|
||||
isLogoutPending,
|
||||
loadIdentity,
|
||||
loadLastUserId,
|
||||
markLogoutPending,
|
||||
saveIdentity,
|
||||
saveLastUserId,
|
||||
} from "./offline-identity";
|
||||
import { removeOtherUsersData, restoreOfflineShell, wipeDeviceData } from "./session-cleanup";
|
||||
import { acceptRefreshedToken, getAccessToken, setAccessToken } from "./token-store";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./api")>()),
|
||||
refreshRequest: vi.fn(),
|
||||
getCurrentUserRequest: vi.fn(),
|
||||
loginRequest: vi.fn(),
|
||||
logoutRequest: vi.fn(),
|
||||
verifyMfaRequest: vi.fn(),
|
||||
ssoCompleteRequest: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({
|
||||
cancelSync: vi.fn().mockResolvedValue(undefined),
|
||||
resumeSync: vi.fn(),
|
||||
syncPendingEntries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/photos/store", () => ({ finishHandOvers: vi.fn().mockResolvedValue(0) }));
|
||||
vi.mock("./session-cleanup", () => ({
|
||||
wipeDeviceData: vi.fn().mockResolvedValue(true),
|
||||
removeOtherUsersData: vi.fn().mockResolvedValue(undefined),
|
||||
restoreOfflineShell: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const ALICE: AuthUser = {
|
||||
id: "user-alice",
|
||||
email: "[email protected]",
|
||||
name: "Alice",
|
||||
roleKey: "admin",
|
||||
roleName: "Admin",
|
||||
permissions: ["colonies.read"],
|
||||
mfaEnabled: false,
|
||||
hasAvatar: false,
|
||||
};
|
||||
const BOB: AuthUser = { ...ALICE, id: "user-bob", email: "[email protected]", name: "Bob" };
|
||||
const ACCESS_TOKEN = "secret-access-token-4711";
|
||||
const INSPECTION = { colonyId: "colony-1", date: "2026-10-14T00:00:00.000Z" };
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const refreshMock = vi.mocked(refreshRequest);
|
||||
const profileMock = vi.mocked(getCurrentUserRequest);
|
||||
const loginMock = vi.mocked(loginRequest);
|
||||
const logoutRequestMock = vi.mocked(logoutRequest);
|
||||
const cancelSyncMock = vi.mocked(cancelSync);
|
||||
const wipeMock = vi.mocked(wipeDeviceData);
|
||||
|
||||
let latest: ReturnType<typeof useAuth>;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
function Probe() {
|
||||
const auth = useAuth();
|
||||
// After every committed render, so `latest` is what the screen shows.
|
||||
useEffect(() => {
|
||||
latest = auth;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderAuth(): Promise<void> {
|
||||
queryClient = new QueryClient();
|
||||
vi.spyOn(queryClient, "clear");
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Probe />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(latest.isLoading).toBe(false));
|
||||
}
|
||||
|
||||
const NETWORK_FAILURE = new TypeError("Failed to fetch");
|
||||
|
||||
function serverKnowsAlice(): void {
|
||||
refreshMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
}
|
||||
|
||||
async function emptyDevice(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await Promise.all([...db.objectStoreNames].map((name) => db.clear(name)));
|
||||
window.localStorage.clear();
|
||||
}
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
cancelSyncMock.mockResolvedValue(undefined);
|
||||
wipeMock.mockResolvedValue(true);
|
||||
logoutRequestMock.mockResolvedValue(undefined);
|
||||
setAccessToken(null);
|
||||
reportRequestOutcome("reached-server");
|
||||
await emptyDevice();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
reportRequestOutcome("reached-server");
|
||||
});
|
||||
|
||||
describe("cold start", () => {
|
||||
it("signs in with a fresh refresh and keeps who it is for offline", async () => {
|
||||
serverKnowsAlice();
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("online");
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
expect(await loadLastUserId()).toBe(ALICE.id);
|
||||
// Sync stays closed until the offline band opens it for this user.
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cold start offline with a cached identity shows the app in offline mode", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cold start offline without or with an expired identity goes to login", async () => {
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await renderAuth();
|
||||
expect(latest.user).toBeNull();
|
||||
|
||||
await saveIdentity(ALICE, Date.now() - 8 * DAY_MS);
|
||||
cleanup();
|
||||
await renderAuth();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("cold start with a revoked session goes to login, drops the identity and keeps the queue", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
refreshMock.mockResolvedValue(null);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
});
|
||||
|
||||
it("a pending logout revokes the session before any refresh", async () => {
|
||||
markLogoutPending();
|
||||
serverKnowsAlice();
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(logoutRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("stays signed out and keeps the pending logout while the network is still down", async () => {
|
||||
markLogoutPending();
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
await saveIdentity(ALICE);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
expect(latest.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("offline session", () => {
|
||||
async function startOfflineAsAlice(): Promise<void> {
|
||||
await saveIdentity(ALICE);
|
||||
await saveLastUserId(ALICE.id);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValueOnce(NETWORK_FAILURE);
|
||||
await renderAuth();
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
}
|
||||
|
||||
it("back online with a 401 refresh sends the user to login and keeps the queue", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
await queueInspection(INSPECTION, "key-2", ALICE.id);
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockResolvedValue(null);
|
||||
cancelSyncMock.mockClear();
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.user).toBeNull());
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Sitzung abgelaufen – bitte neu anmelden. 2 Einträge warten und werden danach gesendet.",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 2 });
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("back online with a refresh the server could not answer stays offline and keeps the session", async () => {
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockRejectedValue(new TypeError("Refresh unavailable (HTTP 503)."));
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("back online with a valid session gets a token and switches to online", async () => {
|
||||
await startOfflineAsAlice();
|
||||
serverKnowsAlice();
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.sessionMode).toBe("online"));
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(wipeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays offline when the refresh cannot reach the server yet", async () => {
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("another account behind the refresh cookie is a user switch: the device is wiped", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.user).toEqual(BOB));
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: BOB.id, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh outcomes that are not a verdict", () => {
|
||||
it("cold start with a refresh the server could not answer (503) opens the offline session", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
refreshMock.mockRejectedValue(new TypeError("Refresh unavailable (HTTP 503)."));
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a silent refresh in the api client", () => {
|
||||
beforeEach(async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
setAccessToken(ACCESS_TOKEN);
|
||||
});
|
||||
|
||||
it("keeps the signed-in account going when the refreshed token is theirs", async () => {
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await expect(acceptRefreshedToken("fresh-token")).resolves.toBe(true);
|
||||
|
||||
expect(getAccessToken()).toBe("fresh-token");
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("ends the session when another account token comes out (second tab signed in as someone else)", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
await act(async () => {
|
||||
await expect(acceptRefreshedToken("bobs-token")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("anderes Konto"));
|
||||
// The first account entries are never sent under the second.
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(await summarizeQueue(BOB.id)).toMatchObject({ own: 0, foreign: 1 });
|
||||
});
|
||||
|
||||
it("does not revive a session nobody is signed in to", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
await expect(acceptRefreshedToken("stray-token")).resolves.toBe(false);
|
||||
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a sign-in or reconnect that is overtaken", () => {
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it("does not take a token into use when the person logs out during an in-flight reconnect", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValueOnce(NETWORK_FAILURE);
|
||||
await renderAuth();
|
||||
const refresh = deferred<{ accessToken: string } | null>();
|
||||
refreshMock.mockReturnValue(refresh.promise);
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
await act(async () => {
|
||||
refresh.resolve({ accessToken: ACCESS_TOKEN });
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not take a token into use when the person logs out during an in-flight sign-in", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
const profile = deferred<AuthUser>();
|
||||
profileMock.mockReturnValue(profile.promise);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
|
||||
let signingIn: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
signingIn = latest.login("[email protected]", "pw");
|
||||
});
|
||||
await waitFor(() => expect(profileMock).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
await act(async () => {
|
||||
profile.resolve(ALICE);
|
||||
await signingIn;
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("prepares the device before the new token is in use", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
let tokenDuringWipe: string | null = "unset";
|
||||
wipeMock.mockImplementation(async () => {
|
||||
tokenDuringWipe = getAccessToken();
|
||||
return true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(tokenDuringWipe).toBeNull();
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
});
|
||||
|
||||
it("refills the service worker after a sign-in", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(restoreOfflineShell).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
beforeEach(async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
});
|
||||
|
||||
it("closes sync before the token changes", async () => {
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
let tokenWhenSyncWasClosed: string | null = "unset";
|
||||
cancelSyncMock.mockImplementation(async () => {
|
||||
tokenWhenSyncWasClosed = getAccessToken();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(tokenWhenSyncWasClosed).toBeNull();
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logging in as another user wipes the previous user's packs and caches but keeps their queue unsent", async () => {
|
||||
await saveLastUserId(ALICE.id);
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: BOB.id, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(await loadLastUserId()).toBe(BOB.id);
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
expect(await summarizeQueue(BOB.id)).toMatchObject({ own: 0, foreign: 1 });
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the same user logging in again keeps their data and only drops what others left unsaved", async () => {
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(wipeMock).not.toHaveBeenCalled();
|
||||
expect(removeOtherUsersData).toHaveBeenCalledWith(ALICE.id);
|
||||
});
|
||||
|
||||
it("refuses to sign in while an earlier logout could not be sent", async () => {
|
||||
markLogoutPending();
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await act(async () => {
|
||||
await expect(latest.login("[email protected]", "pw")).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
expect(loginMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
});
|
||||
|
||||
it("revokes an earlier pending logout first, then signs in", async () => {
|
||||
markLogoutPending();
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(logoutRequestMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loginMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
});
|
||||
|
||||
it("closes sync before an MFA login changes the token as well", async () => {
|
||||
vi.mocked(verifyMfaRequest).mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.verifyMfa("mfa-token", "123456");
|
||||
});
|
||||
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("never writes the access token to localStorage or IndexedDB", async () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
const idbWrites = [
|
||||
vi.spyOn(IDBObjectStore.prototype, "put"),
|
||||
vi.spyOn(IDBObjectStore.prototype, "add"),
|
||||
];
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
const written = [...setItem.mock.calls, ...idbWrites.flatMap((spy) => spy.mock.calls)];
|
||||
expect(written.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(written)).not.toContain(ACCESS_TOKEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
beforeEach(async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
});
|
||||
|
||||
it("closes sync, revokes the session, then clears the token and the device", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(cancelSyncMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
logoutRequestMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await loadLastUserId()).toBeNull();
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardQueueOf: undefined });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
// Sync stays closed: the next token opens it again.
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the account's own queue only when asked to", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout({ discardQueue: true });
|
||||
});
|
||||
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardQueueOf: ALICE.id });
|
||||
});
|
||||
|
||||
it("logout without network marks the logout as pending and still signs out", async () => {
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the layout mounted for the old user until the user is gone", async () => {
|
||||
let isLoadingDuringLogout = false;
|
||||
logoutRequestMock.mockImplementation(async () => {
|
||||
isLoadingDuringLogout = latest.isLoading;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(isLoadingDuringLogout).toBe(false);
|
||||
expect(latest.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("tells the person when the device could not be cleaned completely", async () => {
|
||||
wipeMock.mockResolvedValue(false);
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
// The next sign-in of anyone cleans again.
|
||||
expect(await loadLastUserId()).toBe(ALICE.id);
|
||||
});
|
||||
|
||||
it("reopens sync when the logout itself fails before the user is gone", async () => {
|
||||
cancelSyncMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
await act(async () => {
|
||||
await expect(latest.logout()).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
expect(resumeSync).toHaveBeenCalled();
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshUser keeps the cached identity current without closing sync", async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
profileMock.mockResolvedValue({ ...ALICE, name: "Alice Neu" });
|
||||
|
||||
await act(async () => {
|
||||
await latest.refreshUser();
|
||||
});
|
||||
|
||||
expect(latest.user?.name).toBe("Alice Neu");
|
||||
expect((await loadIdentity())?.name).toBe("Alice Neu");
|
||||
expect(cancelSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { clearAllFormDrafts } from '@/lib/forms/draft-storage';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { isNetworkError } from '@/lib/http/fetch-with-timeout';
|
||||
import { useConnectivity } from '@/lib/offline/connectivity';
|
||||
import { summarizeQueue } from '@/lib/offline/db';
|
||||
import { sessionExpiredWithEntries } from '@/lib/offline/queue-texts';
|
||||
import { cancelSync, resumeSync } from '@/lib/offline/sync';
|
||||
import { finishHandOvers } from '@/lib/photos/store';
|
||||
import {
|
||||
getCurrentUserRequest,
|
||||
isMfaRequired,
|
||||
@@ -11,7 +26,23 @@ import {
|
||||
ssoCompleteRequest,
|
||||
verifyMfaRequest,
|
||||
} from './api';
|
||||
import { getAccessToken, setAccessToken as storeAccessToken } from './token-store';
|
||||
import {
|
||||
clearIdentity,
|
||||
clearLastUserId,
|
||||
clearLogoutPending,
|
||||
isLogoutPending,
|
||||
loadIdentity,
|
||||
loadLastUserId,
|
||||
markLogoutPending,
|
||||
saveIdentity,
|
||||
saveLastUserId,
|
||||
} from './offline-identity';
|
||||
import { removeOtherUsersData, restoreOfflineShell, wipeDeviceData } from './session-cleanup';
|
||||
import {
|
||||
getAccessToken,
|
||||
setAccessToken as storeAccessToken,
|
||||
setRefreshedTokenHandler,
|
||||
} from './token-store';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -26,104 +57,459 @@ export interface AuthUser {
|
||||
|
||||
export type LoginOutcome = { mfaRequired: false } | { mfaRequired: true; mfaToken: string };
|
||||
|
||||
/**
|
||||
* online: a refresh succeeded and an access token is held in memory.
|
||||
* offline: opened from the cached identity - no token, so nothing can be sent
|
||||
* to the server until a refresh succeeds again.
|
||||
*/
|
||||
export type SessionMode = 'online' | 'offline';
|
||||
|
||||
export interface LogoutOptions {
|
||||
/** Also delete the unsent entries of this account (the person confirmed it). */
|
||||
discardQueue?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
sessionMode: SessionMode;
|
||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||
completeSso: () => Promise<LoginOutcome>;
|
||||
logout: () => Promise<void>;
|
||||
logout: (options?: LogoutOptions) => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
user: AuthUser | null;
|
||||
mode: SessionMode;
|
||||
}
|
||||
|
||||
const SIGNED_OUT: Session = { user: null, mode: 'online' };
|
||||
// While offline: how often to look for the server again.
|
||||
const RECONNECT_INTERVAL_MS = 30_000;
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
async function applyAccessToken(token: string): Promise<AuthUser | null> {
|
||||
storeAccessToken(token);
|
||||
/**
|
||||
* Loads the profile that belongs to a new access token. The token itself is
|
||||
* only taken into use (storeAccessToken) once the device is prepared for that
|
||||
* user. Sync is closed first: nothing queued may be sent under the old token
|
||||
* meanwhile. The user is null when the profile cannot be loaded; a network
|
||||
* failure is thrown.
|
||||
*/
|
||||
async function loadProfile(token: string): Promise<AuthUser | null> {
|
||||
await cancelSync();
|
||||
try {
|
||||
return await getCurrentUserRequest(token);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Revokes a session whose logout could not reach the server earlier. */
|
||||
async function settlePendingLogout(): Promise<boolean> {
|
||||
if (!isLogoutPending()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await logoutRequest();
|
||||
clearLogoutPending();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openOfflineSession(): Promise<Session> {
|
||||
storeAccessToken(null);
|
||||
const identity = await loadIdentity().catch(() => null);
|
||||
return identity ? { user: identity, mode: 'offline' } : SIGNED_OUT;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const connectivity = useConnectivity();
|
||||
const [session, setSession] = useState<Session>(SIGNED_OUT);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const sessionRef = useRef<Session>(SIGNED_OUT);
|
||||
// A logout's cleaning of the device runs on after the user is gone; nobody
|
||||
// signs in before it is done.
|
||||
const cleaningRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const isReconnectingRef = useRef(false);
|
||||
// Raised whenever the session changes or ends. A sign-in or reconnect that
|
||||
// is still on its way checks it after every wait and gives up when it moved.
|
||||
const epochRef = useRef(0);
|
||||
|
||||
const commit = useCallback((next: Session) => {
|
||||
epochRef.current += 1;
|
||||
sessionRef.current = next;
|
||||
setSession(next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Makes `user` the person this device works for. Another account than the
|
||||
* last one is a user switch: the previous person's packs, caches, drafts
|
||||
* and in-memory data are removed (their queue stays, and is never sent
|
||||
* under this account).
|
||||
*/
|
||||
const prepareDevice = useCallback(
|
||||
async (user: AuthUser): Promise<boolean> => {
|
||||
const lastUserId = await loadLastUserId().then(
|
||||
(id) => ({ id, known: true }),
|
||||
() => ({ id: null, known: false }),
|
||||
);
|
||||
const isSwitch = !lastUserId.known || (lastUserId.id !== null && lastUserId.id !== user.id);
|
||||
if (isSwitch) {
|
||||
// Owner-less legacy entries would be sent under this account.
|
||||
await wipeDeviceData({ keepDraftsOf: user.id, discardOwnerless: true });
|
||||
queryClient.clear();
|
||||
} else {
|
||||
await removeOtherUsersData(user.id).catch(() => undefined);
|
||||
}
|
||||
return isSwitch;
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
/** Only once the session is certain: an aborted sign-in leaves no identity behind. */
|
||||
const takeIntoUse = useCallback(
|
||||
async (token: string, user: AuthUser, isSwitch: boolean) => {
|
||||
storeAccessToken(token);
|
||||
await Promise.allSettled([saveLastUserId(user.id), saveIdentity(user)]);
|
||||
commit({ user, mode: 'online' });
|
||||
if (isSwitch) {
|
||||
void restoreOfflineShell();
|
||||
}
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const signInWithToken = useCallback(
|
||||
async (token: string) => {
|
||||
const epoch = epochRef.current;
|
||||
await cleaningRef.current;
|
||||
const user = await loadProfile(token);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
commit(SIGNED_OUT);
|
||||
return;
|
||||
}
|
||||
await prepareDevice(user);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
// A sign-in always refills the worker: the wipe of the last logout may
|
||||
// have emptied it, and the backend address never passes it again.
|
||||
await takeIntoUse(token, user, true);
|
||||
},
|
||||
[commit, prepareDevice, takeIntoUse],
|
||||
);
|
||||
|
||||
/** The session is over on the server: back to login, the queue stays. */
|
||||
const expireSession = useCallback(async () => {
|
||||
epochRef.current += 1;
|
||||
await cancelSync();
|
||||
storeAccessToken(null);
|
||||
const userId = sessionRef.current.user?.id;
|
||||
const waiting = userId
|
||||
? await summarizeQueue(userId).then(
|
||||
(summary) => summary.own + summary.failed,
|
||||
() => 0,
|
||||
)
|
||||
: 0;
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
toast.error(sessionExpiredWithEntries(waiting), { duration: 10_000 });
|
||||
commit(SIGNED_OUT);
|
||||
}, [commit, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRequest()
|
||||
.then(async (result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
let isCurrent = true;
|
||||
|
||||
async function start(): Promise<Session> {
|
||||
// Nothing is sent until a token is set and the offline band reopens sync.
|
||||
await cancelSync();
|
||||
if (isLogoutPending()) {
|
||||
await settlePendingLogout();
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed = await refreshRequest();
|
||||
} catch {
|
||||
return openOfflineSession();
|
||||
}
|
||||
if (!refreshed) {
|
||||
await clearIdentity().catch(() => undefined);
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
try {
|
||||
const user = await loadProfile(refreshed.accessToken);
|
||||
if (!user) {
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string): Promise<LoginOutcome> => {
|
||||
const result = await loginRequest(email, password);
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
const isSwitch = await prepareDevice(user);
|
||||
storeAccessToken(refreshed.accessToken);
|
||||
await Promise.allSettled([saveLastUserId(user.id), saveIdentity(user)]);
|
||||
if (isSwitch) {
|
||||
void restoreOfflineShell();
|
||||
}
|
||||
return { user, mode: 'online' };
|
||||
} catch {
|
||||
return openOfflineSession();
|
||||
}
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
return { mfaRequired: false };
|
||||
|
||||
start()
|
||||
.catch(() => SIGNED_OUT)
|
||||
.then((next) => {
|
||||
if (isCurrent) {
|
||||
commit(next);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [commit, prepareDevice]);
|
||||
|
||||
// Offline session: look for the server again, and take a token as soon as
|
||||
// the refresh works. A refresh the server refuses ends the session.
|
||||
const reconnect = useCallback(async () => {
|
||||
if (isReconnectingRef.current || sessionRef.current.mode !== 'offline') {
|
||||
return;
|
||||
}
|
||||
isReconnectingRef.current = true;
|
||||
const epoch = epochRef.current;
|
||||
try {
|
||||
const refreshed = await refreshRequest().catch(() => undefined);
|
||||
// No answer, or the person signed out / switched while waiting.
|
||||
if (refreshed === undefined || epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!refreshed) {
|
||||
await expireSession();
|
||||
return;
|
||||
}
|
||||
const user = await loadProfile(refreshed.accessToken);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
await expireSession();
|
||||
return;
|
||||
}
|
||||
const isSwitch = await prepareDevice(user);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
await takeIntoUse(refreshed.accessToken, user, isSwitch);
|
||||
} catch {
|
||||
// The server went away again: still an offline session.
|
||||
} finally {
|
||||
isReconnectingRef.current = false;
|
||||
}
|
||||
}, [expireSession, prepareDevice, takeIntoUse]);
|
||||
|
||||
const isOfflineSession = session.mode === 'offline' && session.user !== null;
|
||||
useEffect(() => {
|
||||
if (!isOfflineSession) {
|
||||
return;
|
||||
}
|
||||
// Not in the effect body itself: a refresh result sets state.
|
||||
const first = connectivity === 'online' ? setTimeout(() => void reconnect(), 0) : undefined;
|
||||
const handleOnline = () => void reconnect();
|
||||
window.addEventListener('online', handleOnline);
|
||||
const timer = setInterval(() => {
|
||||
if (navigator.onLine !== false) {
|
||||
void reconnect();
|
||||
}
|
||||
}, RECONNECT_INTERVAL_MS);
|
||||
return () => {
|
||||
clearTimeout(first);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [isOfflineSession, connectivity, reconnect]);
|
||||
|
||||
// A silent refresh in the api client (after a 401) may hand out a token of
|
||||
// another account: the refresh cookie is shared by all tabs. Only the
|
||||
// account this tab shows may continue; anyone else ends this session here.
|
||||
useEffect(() => {
|
||||
setRefreshedTokenHandler(async (token) => {
|
||||
const current = sessionRef.current.user;
|
||||
if (!current) {
|
||||
// Nobody is signed in: a refresh must not revive a session.
|
||||
return false;
|
||||
}
|
||||
const epoch = epochRef.current;
|
||||
const profile = await getCurrentUserRequest(token).catch((error: unknown) => {
|
||||
if (isNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (epochRef.current !== epoch || !profile) {
|
||||
return false;
|
||||
}
|
||||
if (profile.id === current.id) {
|
||||
storeAccessToken(token);
|
||||
if (sessionRef.current.mode === 'offline') {
|
||||
commit({ user: profile, mode: 'online' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
epochRef.current += 1;
|
||||
await cancelSync();
|
||||
storeAccessToken(null);
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
commit(SIGNED_OUT);
|
||||
toast.error('Ein anderes Konto wurde angemeldet – bitte neu anmelden.');
|
||||
const isClean = await wipeDeviceData({ keepDraftsOf: null, discardOwnerless: true });
|
||||
if (!isClean) {
|
||||
toast.error('Nicht alle Daten konnten von diesem Gerät gelöscht werden.');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return () => setRefreshedTokenHandler(null);
|
||||
}, [commit, queryClient]);
|
||||
|
||||
// A logout the server never heard of is revoked at the next network contact.
|
||||
useEffect(() => {
|
||||
const handleOnline = () => void settlePendingLogout();
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => window.removeEventListener('online', handleOnline);
|
||||
}, []);
|
||||
|
||||
const verifyMfa = useCallback(async (mfaToken: string, code: string) => {
|
||||
const result = await verifyMfaRequest(mfaToken, code);
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
/**
|
||||
* A new sign-in starts by revoking an unsent logout: with the old cookie
|
||||
* still alive, the pending logout would otherwise end the new session.
|
||||
*/
|
||||
const beginSignIn = useCallback(async () => {
|
||||
await cleaningRef.current;
|
||||
if (!(await settlePendingLogout())) {
|
||||
throw new TypeError('A pending logout has not reached the server yet.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string): Promise<LoginOutcome> => {
|
||||
await beginSignIn();
|
||||
const result = await loginRequest(email, password);
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
await signInWithToken(result.accessToken);
|
||||
return { mfaRequired: false };
|
||||
},
|
||||
[beginSignIn, signInWithToken],
|
||||
);
|
||||
|
||||
const verifyMfa = useCallback(
|
||||
async (mfaToken: string, code: string) => {
|
||||
const result = await verifyMfaRequest(mfaToken, code);
|
||||
await signInWithToken(result.accessToken);
|
||||
},
|
||||
[signInWithToken],
|
||||
);
|
||||
|
||||
const completeSso = useCallback(async (): Promise<LoginOutcome> => {
|
||||
await beginSignIn();
|
||||
const result = await ssoCompleteRequest();
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
await signInWithToken(result.accessToken);
|
||||
return { mfaRequired: false };
|
||||
}, []);
|
||||
}, [beginSignIn, signInWithToken]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutRequest();
|
||||
storeAccessToken(null);
|
||||
setUser(null);
|
||||
// Unfinished form entries belong to the person who typed them.
|
||||
clearAllFormDrafts();
|
||||
// The service worker cache now holds real business data (colonies,
|
||||
// tasks, etc.) for offline viewing - on a shared/handed-off device
|
||||
// that must not survive into the next person's session.
|
||||
if ("caches" in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
}, []);
|
||||
const logout = useCallback(
|
||||
async ({ discardQueue = false }: LogoutOptions = {}) => {
|
||||
const current = sessionRef.current.user;
|
||||
let isSignedOut = false;
|
||||
epochRef.current += 1;
|
||||
try {
|
||||
// Nothing may be sent under a session that is being ended.
|
||||
await cancelSync();
|
||||
if (current) {
|
||||
// Photos of an online save that is not handed over yet would be
|
||||
// deleted with the drafts below.
|
||||
await finishHandOvers(current.id).catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
await logoutRequest();
|
||||
clearLogoutPending();
|
||||
} catch {
|
||||
// No network: the cookie lives on. Revoked at the next contact.
|
||||
markLogoutPending();
|
||||
}
|
||||
storeAccessToken(null);
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
// The user is gone before the device is cleaned: nothing of theirs
|
||||
// stays on screen while the caches are deleted.
|
||||
commit(SIGNED_OUT);
|
||||
isSignedOut = true;
|
||||
const cleaning = wipeDeviceData({
|
||||
keepDraftsOf: null,
|
||||
discardQueueOf: discardQueue && current ? current.id : undefined,
|
||||
});
|
||||
cleaningRef.current = cleaning;
|
||||
const isClean = await cleaning;
|
||||
if (isClean) {
|
||||
await clearLastUserId().catch(() => undefined);
|
||||
} else {
|
||||
toast.error('Nicht alle Daten konnten von diesem Gerät gelöscht werden.');
|
||||
}
|
||||
} finally {
|
||||
if (!isSignedOut) {
|
||||
// The logout was not carried out: the signed-in user keeps syncing.
|
||||
resumeSync();
|
||||
}
|
||||
}
|
||||
},
|
||||
[commit, queryClient],
|
||||
);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permission: string) => user?.permissions.includes(permission) ?? false,
|
||||
[user],
|
||||
(permission: string) => session.user?.permissions.includes(permission) ?? false,
|
||||
[session.user],
|
||||
);
|
||||
|
||||
// Re-fetches the profile with the access token already held in memory -
|
||||
// no need to rotate the refresh-token cookie just to reflect a self-edit
|
||||
// (name/email/avatar/MFA change) in the cached user.
|
||||
// (name/email/avatar/MFA change) in the cached user. The token stays the
|
||||
// same, so sync stays open.
|
||||
const refreshUser = useCallback(async () => {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
setUser(await applyAccessToken(token));
|
||||
}, []);
|
||||
try {
|
||||
const user = await getCurrentUserRequest(token);
|
||||
commit({ user, mode: sessionRef.current.mode });
|
||||
await saveIdentity(user).catch(() => undefined);
|
||||
} catch (error) {
|
||||
if (!isNetworkError(error)) {
|
||||
commit(SIGNED_OUT);
|
||||
}
|
||||
}
|
||||
}, [commit]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
user: session.user,
|
||||
isLoading,
|
||||
sessionMode: session.mode,
|
||||
login,
|
||||
verifyMfa,
|
||||
completeSso,
|
||||
@@ -142,6 +528,11 @@ export function useCurrentUserId(): string | null {
|
||||
return useContext(AuthContext)?.user?.id ?? null;
|
||||
}
|
||||
|
||||
/** online, unless this is an offline session - usable outside an AuthProvider (tests). */
|
||||
export function useSessionMode(): SessionMode {
|
||||
return useContext(AuthContext)?.sessionMode ?? 'online';
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type DbModule = typeof import("@/lib/offline/db");
|
||||
type LegacyModule = typeof import("./legacy-queue");
|
||||
|
||||
let db: DbModule;
|
||||
let legacy: LegacyModule;
|
||||
|
||||
const INSPECTION = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z" };
|
||||
const TREATMENT = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" };
|
||||
|
||||
/** An entry queued before owners existed (DB v2): it has no ownerUserId. */
|
||||
async function queueOwnerless(): Promise<void> {
|
||||
const connection = await db.getDb();
|
||||
await connection.add("pending-inspections", {
|
||||
input: INSPECTION,
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("owner-less queue entries", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/offline/db");
|
||||
legacy = await import("./legacy-queue");
|
||||
});
|
||||
|
||||
it("deletes only the owner-less entries", async () => {
|
||||
await queueOwnerless();
|
||||
await db.queueTreatment(TREATMENT, "key-legacy-sibling", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-own", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-foreign", "user-2");
|
||||
|
||||
expect(await db.summarizeQueue("user-1")).toMatchObject({ own: 3 });
|
||||
|
||||
await legacy.clearOwnerlessEntries();
|
||||
|
||||
// Own and foreign entries stay; the owner-less one is gone.
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 0, foreign: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getDb, type QueueStoreName } from "@/lib/offline/db";
|
||||
|
||||
/*
|
||||
* Entries queued before owners existed (DB v2) have no ownerUserId, so
|
||||
* clearQueueOf(userId) never reaches them - yet they are sent under whoever
|
||||
* signs in next. The logout dialog counts them with the person's own entries
|
||||
* (summarizeQueue treats them as own) and a discarding logout deletes them,
|
||||
* so they cannot end up in the next person's account.
|
||||
*/
|
||||
|
||||
type QueuedEntry = { id: number; ownerUserId?: string };
|
||||
|
||||
/** Every store that holds queued entries (all pending-* stores except photos). */
|
||||
async function queueStores(): Promise<QueueStoreName[]> {
|
||||
const db = await getDb();
|
||||
return [...db.objectStoreNames].filter(
|
||||
(name): name is QueueStoreName => name.startsWith("pending-") && name !== "pending-photos",
|
||||
);
|
||||
}
|
||||
|
||||
async function ownerlessEntries(store: QueueStoreName): Promise<QueuedEntry[]> {
|
||||
const db = await getDb();
|
||||
const entries = (await db.getAll(store)) as QueuedEntry[];
|
||||
return entries.filter((entry) => entry.ownerUserId === undefined);
|
||||
}
|
||||
|
||||
export async function clearOwnerlessEntries(): Promise<void> {
|
||||
const db = await getDb();
|
||||
for (const store of await queueStores()) {
|
||||
for (const entry of await ownerlessEntries(store)) {
|
||||
await db.delete(store, entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthUser } from "./auth-context";
|
||||
|
||||
type IdentityModule = typeof import("./offline-identity");
|
||||
type DbModule = typeof import("@/lib/offline/db");
|
||||
|
||||
let identity: IdentityModule;
|
||||
let db: DbModule;
|
||||
|
||||
const USER: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "[email protected]",
|
||||
name: "Test Imker",
|
||||
roleKey: "admin",
|
||||
roleName: "Admin",
|
||||
permissions: ["colonies.read"],
|
||||
mfaEnabled: true,
|
||||
hasAvatar: false,
|
||||
};
|
||||
|
||||
// Compile-time list: a new AuthUser field must be decided on here.
|
||||
const AUTH_USER_KEYS: Record<keyof AuthUser, true> = {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
roleKey: true,
|
||||
roleName: true,
|
||||
permissions: true,
|
||||
mfaEnabled: true,
|
||||
hasAvatar: true,
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const NOW = Date.parse("2026-10-14T08:00:00.000Z");
|
||||
|
||||
describe("offline identity", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
window.localStorage.clear();
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/offline/db");
|
||||
identity = await import("./offline-identity");
|
||||
});
|
||||
|
||||
it("stores exactly the user fields, never a token", async () => {
|
||||
const withToken = { ...USER, accessToken: "secret-token", refreshToken: "secret-refresh" };
|
||||
|
||||
await identity.saveIdentity(withToken as AuthUser, NOW);
|
||||
|
||||
const stored = (await (await db.getDb()).get("meta", "identity")) as {
|
||||
user: Record<string, unknown>;
|
||||
cachedAt: number;
|
||||
};
|
||||
expect(Object.keys(stored.user).sort()).toEqual(Object.keys(AUTH_USER_KEYS).sort());
|
||||
expect(JSON.stringify(stored)).not.toContain("secret");
|
||||
expect(stored.cachedAt).toBe(NOW);
|
||||
expect(await identity.loadIdentity(NOW)).toEqual(USER);
|
||||
});
|
||||
|
||||
it("expires after 7 days", async () => {
|
||||
await identity.saveIdentity(USER, NOW);
|
||||
|
||||
expect(await identity.loadIdentity(NOW + 7 * DAY_MS)).toEqual(USER);
|
||||
expect(await identity.loadIdentity(NOW + 7 * DAY_MS + 1)).toBeNull();
|
||||
// Gone for good, not only hidden.
|
||||
expect(await (await db.getDb()).get("meta", "identity")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null without an identity and after it was cleared", async () => {
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
await identity.saveIdentity(USER, NOW);
|
||||
await identity.clearIdentity();
|
||||
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("discards a stored value that is not a complete user", async () => {
|
||||
await (await db.getDb()).put("meta", { user: { id: "user-1" }, cachedAt: NOW }, "identity");
|
||||
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers which user was signed in last, apart from the identity", async () => {
|
||||
expect(await identity.loadLastUserId()).toBeNull();
|
||||
await identity.saveLastUserId("user-1");
|
||||
await identity.clearIdentity();
|
||||
|
||||
expect(await identity.loadLastUserId()).toBe("user-1");
|
||||
|
||||
await identity.clearLastUserId();
|
||||
expect(await identity.loadLastUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it("notes a pending logout on this device until it is cleared", () => {
|
||||
expect(identity.isLogoutPending()).toBe(false);
|
||||
|
||||
identity.markLogoutPending();
|
||||
expect(identity.isLogoutPending()).toBe(true);
|
||||
expect(window.localStorage.getItem("apicore:logout-pending")).not.toBeNull();
|
||||
|
||||
identity.clearLogoutPending();
|
||||
expect(identity.isLogoutPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import type { AuthUser } from "./auth-context";
|
||||
|
||||
/*
|
||||
* Who was signed in, so the app can open without network (offline session).
|
||||
* Only the profile fields live here - no access token, no refresh token: they
|
||||
* stay in memory and in the httpOnly cookie. An offline session therefore can
|
||||
* show cached data and queue input, but it cannot talk to the server until a
|
||||
* refresh succeeds again.
|
||||
*/
|
||||
|
||||
/** As long as the refresh session itself lives (SESSION_MAX_AGE of the backend). */
|
||||
export const OFFLINE_IDENTITY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const IDENTITY_KEY = "identity";
|
||||
const LAST_USER_KEY = "lastUserId";
|
||||
const LOGOUT_PENDING_KEY = "apicore:logout-pending";
|
||||
|
||||
interface StoredIdentity {
|
||||
user: AuthUser;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/** Copies the known fields only - whatever else the object carries is dropped. */
|
||||
function pickUser(user: AuthUser): AuthUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
roleKey: user.roleKey,
|
||||
roleName: user.roleName,
|
||||
permissions: [...user.permissions],
|
||||
mfaEnabled: user.mfaEnabled,
|
||||
hasAvatar: user.hasAvatar,
|
||||
};
|
||||
}
|
||||
|
||||
function isUser(value: unknown): value is AuthUser {
|
||||
const user = value as Partial<AuthUser> | null;
|
||||
return (
|
||||
typeof user?.id === "string" &&
|
||||
typeof user.email === "string" &&
|
||||
typeof user.name === "string" &&
|
||||
typeof user.roleKey === "string" &&
|
||||
typeof user.roleName === "string" &&
|
||||
Array.isArray(user.permissions) &&
|
||||
user.permissions.every((permission) => typeof permission === "string") &&
|
||||
typeof user.mfaEnabled === "boolean" &&
|
||||
typeof user.hasAvatar === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function isStoredIdentity(value: unknown): value is StoredIdentity {
|
||||
const stored = value as Partial<StoredIdentity> | null;
|
||||
return typeof stored?.cachedAt === "number" && isUser(stored.user);
|
||||
}
|
||||
|
||||
export async function saveIdentity(user: AuthUser, now: number = Date.now()): Promise<void> {
|
||||
const db = await getDb();
|
||||
const stored: StoredIdentity = { user: pickUser(user), cachedAt: now };
|
||||
await db.put("meta", stored, IDENTITY_KEY);
|
||||
}
|
||||
|
||||
/** The cached user, or null when there is none, it is incomplete or older than 7 days. */
|
||||
export async function loadIdentity(now: number = Date.now()): Promise<AuthUser | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("meta", IDENTITY_KEY);
|
||||
if (isStoredIdentity(stored) && now - stored.cachedAt <= OFFLINE_IDENTITY_MAX_AGE_MS) {
|
||||
return pickUser(stored.user);
|
||||
}
|
||||
if (stored !== undefined) {
|
||||
await db.delete("meta", IDENTITY_KEY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function clearIdentity(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("meta", IDENTITY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user who was signed in last - kept when the session just expires, so
|
||||
* the next sign-in of another account is recognised as a user switch.
|
||||
*/
|
||||
export async function loadLastUserId(): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("meta", LAST_USER_KEY);
|
||||
return typeof stored === "string" ? stored : null;
|
||||
}
|
||||
|
||||
export async function saveLastUserId(userId: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put("meta", userId, LAST_USER_KEY);
|
||||
}
|
||||
|
||||
export async function clearLastUserId(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("meta", LAST_USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* A logout without network could not revoke the session on the server. This
|
||||
* note (no secret in it) makes the next start or sign-in do that first.
|
||||
* localStorage on purpose: it must be readable synchronously and survive a
|
||||
* failing IndexedDB.
|
||||
*/
|
||||
export function markLogoutPending(): void {
|
||||
try {
|
||||
window.localStorage.setItem(LOGOUT_PENDING_KEY, "1");
|
||||
} catch {
|
||||
// Storage unavailable: the cookie then expires on its own.
|
||||
}
|
||||
}
|
||||
|
||||
export function isLogoutPending(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(LOGOUT_PENDING_KEY) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLogoutPending(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(LOGOUT_PENDING_KEY);
|
||||
} catch {
|
||||
// Storage unavailable - nothing stored either.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { clearAllFormDrafts } from "@/lib/forms/draft-storage";
|
||||
import { clearQueueOf } from "@/lib/offline/db";
|
||||
import { forgetHandOversExcept } from "@/lib/photos/handover";
|
||||
import { discardDraftPhotosExcept } from "@/lib/photos/store";
|
||||
import { clearAllRounds } from "@/lib/rounds/round-state";
|
||||
import { clearOwnerlessEntries } from "./legacy-queue";
|
||||
import {
|
||||
removeOtherUsersData,
|
||||
restoreOfflineShell,
|
||||
wipeCacheStorage,
|
||||
wipeDeviceData,
|
||||
} from "./session-cleanup";
|
||||
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ deleteAllPacks: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock("@/lib/forms/draft-storage", () => ({ clearAllFormDrafts: vi.fn() }));
|
||||
vi.mock("@/lib/offline/db", () => ({ clearQueueOf: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock("@/lib/photos/handover", () => ({ forgetHandOversExcept: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
discardDraftPhotosExcept: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rounds/round-state", () => ({ clearAllRounds: vi.fn() }));
|
||||
vi.mock("./legacy-queue", () => ({ clearOwnerlessEntries: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
let cacheKeys: string[];
|
||||
let deleteCache: ReturnType<typeof vi.fn>;
|
||||
let postMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
function stubServiceWorker(replyWith: "done" | "silence"): void {
|
||||
postMessage = vi.fn((_message: unknown, [port]: MessagePort[]) => {
|
||||
if (replyWith === "done") {
|
||||
port.postMessage({ type: "WIPE_CACHES_DONE" });
|
||||
}
|
||||
});
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
serviceWorker: { getRegistration: async () => ({ active: { postMessage } }) },
|
||||
});
|
||||
}
|
||||
|
||||
describe("session cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cacheKeys = ["apicore-shell-v4", "apicore-data-v2"];
|
||||
deleteCache = vi.fn().mockResolvedValue(true);
|
||||
vi.stubGlobal("caches", { keys: async () => cacheKeys, delete: deleteCache });
|
||||
stubServiceWorker("done");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("wipeCacheStorage", () => {
|
||||
it("tells the service worker first, then deletes every cache", async () => {
|
||||
await wipeCacheStorage();
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: "WIPE_CACHES" }, expect.any(Array));
|
||||
expect(deleteCache).toHaveBeenCalledWith("apicore-shell-v4");
|
||||
expect(deleteCache).toHaveBeenCalledWith("apicore-data-v2");
|
||||
expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deleteCache.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not wait for a service worker that never answers", async () => {
|
||||
vi.useFakeTimers();
|
||||
stubServiceWorker("silence");
|
||||
|
||||
const wiped = wipeCacheStorage();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await wiped;
|
||||
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("still deletes the caches without a service worker", async () => {
|
||||
vi.stubGlobal("navigator", { ...navigator, serviceWorker: undefined });
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
await wipeCacheStorage();
|
||||
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wipeDeviceData", () => {
|
||||
it("removes what the person left on the device and keeps their queue by default", async () => {
|
||||
const ok = await wipeDeviceData({ keepDraftsOf: null });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(clearAllFormDrafts).toHaveBeenCalled();
|
||||
expect(clearAllRounds).toHaveBeenCalled();
|
||||
expect(deleteAllPacks).toHaveBeenCalled();
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalledWith(null);
|
||||
expect(forgetHandOversExcept).toHaveBeenCalledWith(null);
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
expect(clearQueueOf).not.toHaveBeenCalled();
|
||||
expect(clearOwnerlessEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also deletes the queue of the given account and every owner-less entry on request", async () => {
|
||||
await wipeDeviceData({ keepDraftsOf: null, discardQueueOf: "user-1" });
|
||||
|
||||
expect(clearQueueOf).toHaveBeenCalledWith("user-1");
|
||||
expect(clearOwnerlessEntries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes owner-less legacy entries on a user switch without touching anyone's queue", async () => {
|
||||
await wipeDeviceData({ keepDraftsOf: "user-2", discardOwnerless: true });
|
||||
|
||||
expect(clearOwnerlessEntries).toHaveBeenCalled();
|
||||
expect(clearQueueOf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps going when one step fails and reports it", async () => {
|
||||
vi.mocked(deleteAllPacks).mockRejectedValueOnce(new Error("IndexedDB blocked"));
|
||||
|
||||
const ok = await wipeDeviceData({ keepDraftsOf: null });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalled();
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeOtherUsersData", () => {
|
||||
it("drops the unsaved photos and hand-overs of every other account", async () => {
|
||||
await removeOtherUsersData("user-2");
|
||||
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalledWith("user-2");
|
||||
expect(forgetHandOversExcept).toHaveBeenCalledWith("user-2");
|
||||
expect(deleteAllPacks).not.toHaveBeenCalled();
|
||||
expect(deleteCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreOfflineShell", () => {
|
||||
it("lets the service worker see the runtime config again, then warms the shell", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("{}"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const warm = vi.fn();
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
serviceWorker: { getRegistration: async () => ({ active: { postMessage: warm } }) },
|
||||
});
|
||||
|
||||
await restoreOfflineShell();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/runtime-config", expect.anything());
|
||||
expect(warm).toHaveBeenCalledWith({ type: "WARM_SHELL" });
|
||||
expect(fetchMock.mock.invocationCallOrder[0]).toBeLessThan(warm.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("never throws, whatever is missing", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("offline")));
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
await expect(restoreOfflineShell()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { clearAllFormDrafts } from "@/lib/forms/draft-storage";
|
||||
import { clearQueueOf } from "@/lib/offline/db";
|
||||
import { forgetHandOversExcept } from "@/lib/photos/handover";
|
||||
import { discardDraftPhotosExcept } from "@/lib/photos/store";
|
||||
import { clearAllRounds } from "@/lib/rounds/round-state";
|
||||
import { clearOwnerlessEntries } from "./legacy-queue";
|
||||
|
||||
/*
|
||||
* What a person leaves on a shared phone besides their session: unfinished
|
||||
* forms, a round, offline packs, unsaved photos, hand-over notes and the
|
||||
* service worker's copies of business data. Signing out and switching users
|
||||
* remove it; the queue of unsent entries is only deleted when asked for.
|
||||
*/
|
||||
|
||||
// The worker answers within milliseconds; a hung one must not block a logout.
|
||||
const SW_WIPE_TIMEOUT_MS = 3_000;
|
||||
|
||||
/** Asks the active service worker to wipe its caches and waits for its answer. */
|
||||
async function wipeServiceWorkerCaches(): Promise<void> {
|
||||
if (typeof navigator === "undefined" || !navigator.serviceWorker) {
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
const worker = registration?.active;
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
const timer = setTimeout(resolve, SW_WIPE_TIMEOUT_MS);
|
||||
channel.port1.onmessage = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
worker.postMessage({ type: "WIPE_CACHES" }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes everything in Cache Storage. The worker goes first: it stops every
|
||||
* running request from writing into the cache afterwards (WIPE_CACHES).
|
||||
*/
|
||||
export async function wipeCacheStorage(): Promise<void> {
|
||||
await wipeServiceWorkerCaches().catch(() => undefined);
|
||||
if (typeof caches === "undefined") {
|
||||
return;
|
||||
}
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
|
||||
interface WipeOptions {
|
||||
/** Signed out: null. Switched to this user: their own unsaved photos stay. */
|
||||
keepDraftsOf: string | null;
|
||||
/** Also delete this account's queue (and the owner-less legacy entries). */
|
||||
discardQueueOf?: string;
|
||||
/**
|
||||
* Also delete the entries that belong to no account (queued before owners
|
||||
* existed): they are sent under whoever signs in, so a user switch must
|
||||
* not leave them for the new account.
|
||||
*/
|
||||
discardOwnerless?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the previous person's data from this device. Every step is tried
|
||||
* even when another failed; the result says whether all of them worked.
|
||||
*/
|
||||
export async function wipeDeviceData({
|
||||
keepDraftsOf,
|
||||
discardQueueOf,
|
||||
discardOwnerless = false,
|
||||
}: WipeOptions): Promise<boolean> {
|
||||
const steps: Array<() => void | Promise<void>> = [
|
||||
clearAllFormDrafts,
|
||||
clearAllRounds,
|
||||
deleteAllPacks,
|
||||
() => discardDraftPhotosExcept(keepDraftsOf),
|
||||
() => forgetHandOversExcept(keepDraftsOf),
|
||||
wipeCacheStorage,
|
||||
];
|
||||
if (discardQueueOf !== undefined) {
|
||||
steps.push(() => clearQueueOf(discardQueueOf));
|
||||
}
|
||||
if (discardQueueOf !== undefined || discardOwnerless) {
|
||||
steps.push(clearOwnerlessEntries);
|
||||
}
|
||||
const results = await Promise.allSettled(steps.map(async (step) => step()));
|
||||
return results.every((result) => result.status === "fulfilled");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same account signs in again: nothing of theirs is removed, but what
|
||||
* other accounts left unsaved (photos, hand-overs) is not carried over.
|
||||
*/
|
||||
export async function removeOtherUsersData(userId: string): Promise<void> {
|
||||
await discardDraftPhotosExcept(userId);
|
||||
forgetHandOversExcept(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* After the caches were wiped in this page's lifetime the service worker is
|
||||
* blind: the runtime config (where the backend is) never passes it again, and
|
||||
* the shell is gone. Fetching the config once teaches it the backend; then it
|
||||
* refills the shell. Best effort, never throws.
|
||||
*/
|
||||
export async function restoreOfflineShell(): Promise<void> {
|
||||
try {
|
||||
await fetch("/api/runtime-config", { cache: "no-store" });
|
||||
if (typeof navigator === "undefined" || !navigator.serviceWorker) {
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
registration?.active?.postMessage({ type: "WARM_SHELL" });
|
||||
} catch {
|
||||
// Offline or no worker: the next online visit refills it.
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,25 @@ export function getAccessToken(): string | null {
|
||||
export function setAccessToken(token: string | null): void {
|
||||
currentAccessToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether an access token that came out of a silent refresh (a 401 in
|
||||
* the api client) may be used. The refresh cookie is shared by every tab, so
|
||||
* it can belong to another account than the one this tab shows.
|
||||
*/
|
||||
export type RefreshedTokenHandler = (token: string) => Promise<boolean>;
|
||||
|
||||
let refreshedTokenHandler: RefreshedTokenHandler | null = null;
|
||||
|
||||
export function setRefreshedTokenHandler(handler: RefreshedTokenHandler | null): void {
|
||||
refreshedTokenHandler = handler;
|
||||
}
|
||||
|
||||
/** True when the token was taken into use and the failed request may be retried. */
|
||||
export async function acceptRefreshedToken(token: string): Promise<boolean> {
|
||||
if (!refreshedTokenHandler) {
|
||||
setAccessToken(token);
|
||||
return true;
|
||||
}
|
||||
return refreshedTokenHandler(token);
|
||||
}
|
||||
@@ -6,6 +6,14 @@ export interface BackupImportSummary {
|
||||
sealed: boolean;
|
||||
}
|
||||
|
||||
/** The newest run of the hourly app-backup job (CronJob). */
|
||||
export interface BackupJobRunStatus {
|
||||
status: "RUNNING" | "SUCCEEDED" | "FAILED";
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BackupScheduleStatus {
|
||||
enabled: boolean;
|
||||
path: string | null;
|
||||
@@ -23,6 +31,8 @@ export interface BackupScheduleStatus {
|
||||
cronSchedule: boolean;
|
||||
retentionCount: boolean;
|
||||
};
|
||||
/** Newest app-backup job run, null before the first one. lastRunAt is the newest backup file. */
|
||||
lastJobRun: BackupJobRunStatus | null;
|
||||
}
|
||||
|
||||
export interface BackupSettings {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
BULK_MAX_COLONIES,
|
||||
createFeedingsBulk,
|
||||
createTreatmentsBulk,
|
||||
createVarroaBulk,
|
||||
invalidColonyIdsOf,
|
||||
RETRY_LATER_MESSAGES,
|
||||
isRetryLaterError,
|
||||
} from "./api";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const KEY = "0123456789abcdef-key";
|
||||
|
||||
describe("bulk api", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset().mockResolvedValue({ bulkId: "b-1", records: [] } as never);
|
||||
});
|
||||
|
||||
it("allows at most 100 colonies, like the server", () => {
|
||||
expect(BULK_MAX_COLONIES).toBe(100);
|
||||
});
|
||||
|
||||
it("sends the Idempotency-Key", async () => {
|
||||
const input = { date: "2026-10-14", product: "Oxuvar", colonyIds: ["a", "b"] };
|
||||
|
||||
await createTreatmentsBulk(input, { idempotencyKey: KEY });
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith("/treatments/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": KEY },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
});
|
||||
|
||||
it("posts feedings and varroa counts to their own bulk endpoints", async () => {
|
||||
await createFeedingsBulk(
|
||||
{ date: "2026-10-14", feedType: "SUGAR_WATER", amount: 2, unit: "l", colonyIds: ["a"] },
|
||||
{ idempotencyKey: KEY },
|
||||
);
|
||||
await createVarroaBulk(
|
||||
{
|
||||
date: "2026-10-14",
|
||||
method: "NATURAL_MITE_FALL",
|
||||
durationMinutes: 4320,
|
||||
entries: [{ colonyId: "a", miteCount: 9 }],
|
||||
},
|
||||
{ idempotencyKey: KEY },
|
||||
);
|
||||
|
||||
expect(vi.mocked(apiFetch).mock.calls.map(([path]) => path)).toEqual([
|
||||
"/feedings/bulk",
|
||||
"/varroa-measurements/bulk",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidColonyIdsOf", () => {
|
||||
it("reads ApiError.body", () => {
|
||||
const error = new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
|
||||
message: "Einige Völker sind nicht (mehr) erfassbar",
|
||||
invalidColonyIds: ["c-3", "c-5"],
|
||||
});
|
||||
|
||||
expect(invalidColonyIdsOf(error)).toEqual(["c-3", "c-5"]);
|
||||
});
|
||||
|
||||
it("is empty for anything else", () => {
|
||||
expect(invalidColonyIdsOf(new ApiError(400, "x", {}))).toEqual([]);
|
||||
expect(invalidColonyIdsOf(new ApiError(400, "x", { invalidColonyIds: "c-3" }))).toEqual([]);
|
||||
expect(invalidColonyIdsOf(new ApiError(400, "x", { invalidColonyIds: ["c-3", 7] }))).toEqual([
|
||||
"c-3",
|
||||
]);
|
||||
expect(invalidColonyIdsOf(new Error("x"))).toEqual([]);
|
||||
expect(invalidColonyIdsOf(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRetryLaterError", () => {
|
||||
it("is true for „wird bereits verarbeitet“ and a just-released key, not for other 409s", () => {
|
||||
expect(
|
||||
isRetryLaterError(
|
||||
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRetryLaterError(
|
||||
new ApiError(409, "Diese Anfrage wurde gerade abgebrochen - bitte erneut senden"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRetryLaterError(
|
||||
new ApiError(409, "Dieser Idempotency-Key wurde bereits für eine andere Aktion verwendet"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isRetryLaterError(new ApiError(400, "wird bereits verarbeitet"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Relative to this file, not the working directory. The backend is absent in
|
||||
// the frontend-only Docker build; the pin below then runs in the full
|
||||
// monorepo checks only.
|
||||
const BACKEND_INTERCEPTOR = resolve(
|
||||
__dirname,
|
||||
"../../../../ApiCore-Backend/src/common/idempotency/idempotency.interceptor.ts",
|
||||
);
|
||||
const hasBackend = existsSync(BACKEND_INTERCEPTOR);
|
||||
|
||||
describe("retry-later messages", () => {
|
||||
// The server has no error code for these two 409s, so the client matches
|
||||
// the German text. The cross-check pins it to the backend source: a reworded
|
||||
// message there fails here instead of silently turning "later" into "rejected".
|
||||
it.each(RETRY_LATER_MESSAGES)("is recognised as retry-later: %s", (message) => {
|
||||
expect(isRetryLaterError(new ApiError(409, message))).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(!hasBackend).each(RETRY_LATER_MESSAGES)("the backend still says: %s", (message) => {
|
||||
expect(readFileSync(BACKEND_INTERCEPTOR, "utf-8")).toContain(message);
|
||||
});
|
||||
|
||||
it("the key-used-for-another-action message is not among them", () => {
|
||||
expect(RETRY_LATER_MESSAGES.some((message) => message.includes("andere Aktion"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import { invalidColonyIdsOfBody } from "./bulk-input";
|
||||
import type { CreateTreatmentInput, Treatment } from "@/lib/treatments/types";
|
||||
import type { CreateFeedingInput, Feeding } from "@/lib/feedings/types";
|
||||
import type { CreateVarroaMeasurementInput, VarroaMeasurement } from "@/lib/varroa/types";
|
||||
|
||||
/** Mirrors ApiCore-Backend/src/common/bulk/bulk-limits.ts - keep both in step. */
|
||||
export const BULK_MAX_COLONIES = 100;
|
||||
|
||||
export interface BulkCreateResult<T> {
|
||||
bulkId: string;
|
||||
records: T[];
|
||||
}
|
||||
|
||||
export interface BulkCreateTreatmentInput extends Omit<CreateTreatmentInput, "colonyId"> {
|
||||
colonyIds: string[];
|
||||
}
|
||||
|
||||
export interface BulkCreateFeedingInput extends Omit<CreateFeedingInput, "colonyId"> {
|
||||
colonyIds: string[];
|
||||
}
|
||||
|
||||
/** What each colony has of its own in a bulk count. */
|
||||
export interface BulkVarroaEntry {
|
||||
colonyId: string;
|
||||
miteCount?: number | null;
|
||||
calculatedValue?: number | null;
|
||||
assessment?: string | null;
|
||||
}
|
||||
|
||||
export interface BulkCreateVarroaInput
|
||||
extends Omit<
|
||||
CreateVarroaMeasurementInput,
|
||||
"colonyId" | "miteCount" | "calculatedValue" | "assessment"
|
||||
> {
|
||||
entries: BulkVarroaEntry[];
|
||||
}
|
||||
|
||||
function postBulk<Record>(
|
||||
path: string,
|
||||
input: unknown,
|
||||
options: CreateOptions,
|
||||
): Promise<BulkCreateResult<Record>> {
|
||||
return apiFetch<BulkCreateResult<Record>>(path, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createTreatmentsBulk(
|
||||
input: BulkCreateTreatmentInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<Treatment>> {
|
||||
return postBulk("/treatments/bulk", input, options);
|
||||
}
|
||||
|
||||
export function createFeedingsBulk(
|
||||
input: BulkCreateFeedingInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<Feeding>> {
|
||||
return postBulk("/feedings/bulk", input, options);
|
||||
}
|
||||
|
||||
export function createVarroaBulk(
|
||||
input: BulkCreateVarroaInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<VarroaMeasurement>> {
|
||||
return postBulk("/varroa-measurements/bulk", input, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colonies a refused bulk entry could not be recorded for (400
|
||||
* `invalidColonyIds`: unknown or ended since the pack was loaded). Empty for
|
||||
* every other error.
|
||||
*/
|
||||
export function invalidColonyIdsOf(error: unknown): string[] {
|
||||
return error instanceof ApiError ? invalidColonyIdsOfBody(error.body) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The two 409 texts of the idempotency interceptor that mean „later“. The
|
||||
* server sends no error code for them; api.spec.ts pins both to its source.
|
||||
*/
|
||||
export const RETRY_LATER_MESSAGES = [
|
||||
"Diese Anfrage wird bereits verarbeitet - bitte kurz warten",
|
||||
"Diese Anfrage wurde gerade abgebrochen - bitte erneut senden",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 409 „wird bereits verarbeitet“ (the same key is still running) and „gerade
|
||||
* abgebrochen“ (it was just released): nothing is wrong with the entry, the
|
||||
* answer is „später erneut“. Other 409s (key used for another action) are real.
|
||||
*/
|
||||
export function isRetryLaterError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.status === 409 &&
|
||||
RETRY_LATER_MESSAGES.some((message) => error.message === message)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colonyIdsOfBulkInput, withoutColonies } from "./bulk-input";
|
||||
|
||||
const TREATMENT = { date: "2026-10-14", product: "Oxuvar", colonyIds: ["a", "b", "c"] };
|
||||
const VARROA = {
|
||||
date: "2026-10-14",
|
||||
method: "NATURAL_MITE_FALL",
|
||||
entries: [
|
||||
{ colonyId: "a", miteCount: 4 },
|
||||
{ colonyId: "b", miteCount: 9 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("colonyIdsOfBulkInput", () => {
|
||||
it("lists the colonies of every kind", () => {
|
||||
expect(colonyIdsOfBulkInput("treatments", TREATMENT)).toEqual(["a", "b", "c"]);
|
||||
expect(colonyIdsOfBulkInput("feedings", TREATMENT)).toEqual(["a", "b", "c"]);
|
||||
expect(colonyIdsOfBulkInput("varroa", VARROA)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("is empty for input of an unknown shape", () => {
|
||||
expect(colonyIdsOfBulkInput("treatments", null)).toEqual([]);
|
||||
expect(colonyIdsOfBulkInput("varroa", { entries: "x" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutColonies", () => {
|
||||
it("drops the colonies and keeps every shared field", () => {
|
||||
expect(withoutColonies("treatments", TREATMENT, ["b"])).toEqual({
|
||||
...TREATMENT,
|
||||
colonyIds: ["a", "c"],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a varroa colony together with its own count", () => {
|
||||
expect(withoutColonies("varroa", VARROA, ["a"])).toEqual({
|
||||
...VARROA,
|
||||
entries: [{ colonyId: "b", miteCount: 9 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not change the input", () => {
|
||||
const before = structuredClone(TREATMENT);
|
||||
withoutColonies("treatments", TREATMENT, ["a"]);
|
||||
expect(TREATMENT).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BulkKind } from "@/lib/offline/db";
|
||||
|
||||
function listOf(input: unknown, key: "colonyIds" | "entries"): unknown[] {
|
||||
const list = (input as Record<string, unknown> | null)?.[key];
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** The colonies a queued bulk entry is for (varroa: one entry per colony). */
|
||||
export function colonyIdsOfBulkInput(kind: BulkKind, input: unknown): string[] {
|
||||
if (kind === "varroa") {
|
||||
return listOf(input, "entries").flatMap((entry) => {
|
||||
const colonyId = (entry as { colonyId?: unknown } | null)?.colonyId;
|
||||
return typeof colonyId === "string" ? [colonyId] : [];
|
||||
});
|
||||
}
|
||||
return listOf(input, "colonyIds").filter((id): id is string => typeof id === "string");
|
||||
}
|
||||
|
||||
/** The input without the given colonies; every shared field stays as entered. */
|
||||
export function withoutColonies(
|
||||
kind: BulkKind,
|
||||
input: unknown,
|
||||
colonyIds: readonly string[],
|
||||
): unknown {
|
||||
if (kind === "varroa") {
|
||||
return {
|
||||
...(input as object),
|
||||
entries: listOf(input, "entries").filter(
|
||||
(entry) => !colonyIds.includes((entry as { colonyId: string }).colonyId),
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(input as object),
|
||||
colonyIds: listOf(input, "colonyIds").filter((id) => !colonyIds.includes(id as string)),
|
||||
};
|
||||
}
|
||||
|
||||
/** `invalidColonyIds` of a server answer body (400 of a bulk endpoint); empty otherwise. */
|
||||
export function invalidColonyIdsOfBody(body: unknown): string[] {
|
||||
const ids = (body as { invalidColonyIds?: unknown } | null)?.invalidColonyIds;
|
||||
return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bulkDestination,
|
||||
bulkQueuedText,
|
||||
bulkSavedText,
|
||||
bulkSelectionError,
|
||||
} from "./capture-texts";
|
||||
|
||||
describe("bulkSelectionError", () => {
|
||||
it("asks for a stand first, then for at least one colony", () => {
|
||||
expect(bulkSelectionError(null, [])).toBe("Bitte einen Stand wählen.");
|
||||
expect(bulkSelectionError("apiary-1", [])).toBe("Bitte mindestens ein Volk wählen.");
|
||||
});
|
||||
|
||||
it("allows 100 colonies and names the limit for 101", () => {
|
||||
const ids = (count: number) => Array.from({ length: count }, (_, index) => `c-${index}`);
|
||||
|
||||
expect(bulkSelectionError("apiary-1", ids(100))).toBeUndefined();
|
||||
expect(bulkSelectionError("apiary-1", ids(101))).toBe("Höchstens 100 Völker auf einmal.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk texts", () => {
|
||||
it("says how many colonies were saved, in the singular too", () => {
|
||||
expect(bulkSavedText("Behandlung", 3)).toBe("Behandlung für 3 Völker gespeichert");
|
||||
expect(bulkSavedText("Fütterung", 1)).toBe("Fütterung für 1 Volk gespeichert");
|
||||
});
|
||||
|
||||
it("says that an entry waits offline", () => {
|
||||
expect(bulkQueuedText("Varroamessung", 4)).toBe(
|
||||
"Kein Netzwerk – Varroamessung für 4 Völker wurde offline gespeichert und wird synchronisiert.",
|
||||
);
|
||||
});
|
||||
|
||||
it("goes back to the apiary, or to the overview without one", () => {
|
||||
expect(bulkDestination("apiary-1")).toBe("/apiaries/apiary-1");
|
||||
expect(bulkDestination(null)).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BULK_MAX_COLONIES } from "./api";
|
||||
|
||||
/** What is wrong with the colonies chosen for a bulk capture, if anything. */
|
||||
export function bulkSelectionError(
|
||||
apiaryId: string | null,
|
||||
colonyIds: readonly string[],
|
||||
): string | undefined {
|
||||
if (!apiaryId) return "Bitte einen Stand wählen.";
|
||||
if (colonyIds.length === 0) return "Bitte mindestens ein Volk wählen.";
|
||||
if (colonyIds.length > BULK_MAX_COLONIES) {
|
||||
return `Höchstens ${BULK_MAX_COLONIES} Völker auf einmal.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function colonies(count: number): string {
|
||||
return count === 1 ? "1 Volk" : `${count} Völker`;
|
||||
}
|
||||
|
||||
/** „Behandlung für 3 Völker gespeichert“ - `kind` is the German name of the record. */
|
||||
export function bulkSavedText(kind: string, count: number): string {
|
||||
return `${kind} für ${colonies(count)} gespeichert`;
|
||||
}
|
||||
|
||||
export function bulkQueuedText(kind: string, count: number): string {
|
||||
return `Kein Netzwerk – ${kind} für ${colonies(count)} wurde offline gespeichert und wird synchronisiert.`;
|
||||
}
|
||||
|
||||
/** Where a saved bulk capture goes next: its stand, or the overview. */
|
||||
export function bulkDestination(apiaryId: string | null): string {
|
||||
return apiaryId ? `/apiaries/${apiaryId}` : "/dashboard";
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { livingColoniesFromPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
|
||||
/** What a bulk choice needs of a colony - a server colony or one from an offline pack. */
|
||||
export type BulkColony = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
|
||||
export interface BulkApiary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Where the choices came from: the server, or - only without network - the loaded packs. */
|
||||
export type ChoiceSource = "server" | "packs";
|
||||
|
||||
interface Choices<T> {
|
||||
items: T[];
|
||||
source: ChoiceSource;
|
||||
}
|
||||
|
||||
/** Prefix of the queries here; they read the server or this device's storage, never both as one. */
|
||||
const BULK_CHOICES_KEY = ["bulk-choices"] as const;
|
||||
|
||||
function byCode(a: BulkColony, b: BulkColony): number {
|
||||
return a.code.localeCompare(b.code, "de", { numeric: true });
|
||||
}
|
||||
|
||||
function livingOf(colonies: readonly BulkColony[]): BulkColony[] {
|
||||
return colonies
|
||||
.filter(isLivingColony)
|
||||
.map(({ id, code, status, apiaryId }) => ({ id, code, status, apiaryId }))
|
||||
.sort(byCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* The apiaries to choose from: the server's active ones, or - only when the
|
||||
* network failed - those of the user's offline packs. Names only: nothing
|
||||
* else of an apiary (location!) is needed or kept.
|
||||
*/
|
||||
export function useApiaryChoices() {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: [...BULK_CHOICES_KEY, userId, "apiaries"],
|
||||
queryFn: (): Promise<Choices<BulkApiary>> =>
|
||||
withPackFallback<Choices<BulkApiary>>(
|
||||
async () => ({
|
||||
source: "server",
|
||||
items: (await fetchApiaries({ status: "ACTIVE" })).map(({ id, name }) => ({ id, name })),
|
||||
}),
|
||||
(packs) =>
|
||||
packs.length === 0
|
||||
? null
|
||||
: {
|
||||
source: "packs",
|
||||
items: packs
|
||||
.map(({ pack }) => ({ id: pack.apiary.id, name: pack.apiary.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||||
},
|
||||
userId ?? "",
|
||||
),
|
||||
// Reads this device's storage when offline, never waits for the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The living colonies of one apiary - ended ones never appear. Without
|
||||
* network the apiary's offline pack answers; with no pack for it the
|
||||
* request fails and the caller says so.
|
||||
*/
|
||||
export function useApiaryColonies(apiaryId: string | null) {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: [...BULK_CHOICES_KEY, userId, "colonies", apiaryId],
|
||||
queryFn: (): Promise<Choices<BulkColony>> =>
|
||||
withPackFallback<Choices<BulkColony>>(
|
||||
async () => ({
|
||||
source: "server",
|
||||
items: livingOf(await fetchColonies({ apiaryId: apiaryId ?? undefined })),
|
||||
}),
|
||||
(packs) =>
|
||||
packs.some((stored) => stored.apiaryId === apiaryId)
|
||||
? {
|
||||
source: "packs",
|
||||
items: livingOf(
|
||||
livingColoniesFromPacks(packs).filter((colony) => colony.apiaryId === apiaryId),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
userId ?? "",
|
||||
),
|
||||
enabled: apiaryId !== null && apiaryId !== "",
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { BulkKind, queueBulk } from "@/lib/offline/db";
|
||||
import { CreateOptions } from "@/lib/offline/idempotency";
|
||||
import {
|
||||
CaptureMutation,
|
||||
CaptureOutcome,
|
||||
useCaptureMutation,
|
||||
} from "@/lib/offline/use-capture-mutation";
|
||||
import { invalidColonyIdsOf, isRetryLaterError } from "./api";
|
||||
|
||||
interface BulkCaptureOptions<I, R> {
|
||||
kind: BulkKind;
|
||||
create: (input: I, options: CreateOptions) => Promise<R>;
|
||||
failureMessage: string;
|
||||
onSaved: (outcome: CaptureOutcome<R>, input: I) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface BulkCapture<I> extends CaptureMutation<I> {
|
||||
/** Colonies the server refused (400): unknown or ended since the list was loaded. */
|
||||
invalidColonyIds: string[];
|
||||
/** 409 „wird bereits verarbeitet“: not an error, just save again in a moment. */
|
||||
isRetryLater: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a capture for several colonies: online as one request, offline as one
|
||||
* queued bulk entry. Every distinct selection gets its own idempotency key
|
||||
* (the server binds a key to the user and action, not to what was sent). A
|
||||
* refusal naming colonies reloads the packs of those colonies at once, like a
|
||||
* rejected queued entry does.
|
||||
*/
|
||||
export function useBulkCapture<I, R>({
|
||||
kind,
|
||||
create,
|
||||
failureMessage,
|
||||
onSaved,
|
||||
}: BulkCaptureOptions<I, R>): BulkCapture<I> {
|
||||
const userId = useCurrentUserId();
|
||||
const capture = useCaptureMutation<I, R>({
|
||||
create,
|
||||
queue: (input, idempotencyKey, ownerUserId) =>
|
||||
queueBulk(kind, input, idempotencyKey, ownerUserId),
|
||||
failureMessage,
|
||||
onSaved,
|
||||
keyPerSubmission: true,
|
||||
});
|
||||
const invalidColonyIds = invalidColonyIdsOf(capture.error);
|
||||
const invalidKey = invalidColonyIds.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (userId && invalidKey) {
|
||||
void refreshPacksAfterSync(userId, Date.now(), undefined, {
|
||||
force: true,
|
||||
colonyIds: invalidKey.split(","),
|
||||
});
|
||||
}
|
||||
}, [userId, invalidKey]);
|
||||
|
||||
return { ...capture, invalidColonyIds, isRetryLater: isRetryLaterError(capture.error) };
|
||||
}
|
||||
@@ -1,9 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { DRAFT_PREFIX, clearAllFormDrafts } from "./draft-storage";
|
||||
import { DRAFT_MAX_AGE_MS, DRAFT_PREFIX, clearAllFormDrafts, readLiveFormDrafts } from "./draft-storage";
|
||||
import { useFormDraft } from "./use-form-draft";
|
||||
|
||||
describe("form drafts", () => {
|
||||
it("reads the values of every draft that can still be restored", () => {
|
||||
window.localStorage.clear();
|
||||
const now = Date.parse("2026-10-13T08:00:00.000Z");
|
||||
const store = (key: string, savedAt: number, values: unknown) =>
|
||||
window.localStorage.setItem(`${DRAFT_PREFIX}${key}`, JSON.stringify({ savedAt, values }));
|
||||
store("user-1:inspection-new", now - DRAFT_MAX_AGE_MS, { photoSessionId: "restorable" });
|
||||
store("user-2:inspection-new", now - 60_000, { photoSessionId: "other-user" });
|
||||
store("user-1:feeding-new", now - DRAFT_MAX_AGE_MS - 1, { photoSessionId: "expired" });
|
||||
window.localStorage.setItem(`${DRAFT_PREFIX}user-1:harvest-new`, "{not json");
|
||||
window.localStorage.setItem("apicore:motion", JSON.stringify({ savedAt: now, values: {} }));
|
||||
|
||||
const live = readLiveFormDrafts(now);
|
||||
|
||||
expect(live).toHaveLength(2);
|
||||
expect(live).toEqual(
|
||||
expect.arrayContaining([{ photoSessionId: "restorable" }, { photoSessionId: "other-user" }]),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes every draft on logout and nothing else", () => {
|
||||
window.localStorage.setItem(`${DRAFT_PREFIX}user-1:inspection-new`, "{}");
|
||||
window.localStorage.setItem("apicore:motion", "subtle");
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const DRAFT_PREFIX = "apicore:draft:";
|
||||
|
||||
/** Drafts older than this are stale (the situation at the hive has changed). */
|
||||
export const DRAFT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Removes every draft on this device - called on logout, so the next
|
||||
* person on a shared phone never sees someone else's unfinished entry.
|
||||
@@ -16,3 +19,33 @@ export function clearAllFormDrafts(): void {
|
||||
// Storage unavailable - nothing stored either.
|
||||
}
|
||||
}
|
||||
|
||||
function liveValues(raw: string | null, now: number): unknown[] {
|
||||
try {
|
||||
const draft = JSON.parse(raw ?? "null") as { savedAt?: unknown; values?: unknown } | null;
|
||||
const isLive = typeof draft?.savedAt === "number" && now - draft.savedAt <= DRAFT_MAX_AGE_MS;
|
||||
return isLive ? [draft.values] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The values of every draft on this device that a form would still offer
|
||||
* (of any user and form) - so nothing a draft refers to is cleaned up early.
|
||||
*/
|
||||
export function readLiveFormDrafts(now: number): unknown[] {
|
||||
try {
|
||||
const drafts: unknown[] = [];
|
||||
for (let index = 0; index < window.localStorage.length; index++) {
|
||||
const storageKey = window.localStorage.key(index);
|
||||
if (storageKey?.startsWith(DRAFT_PREFIX)) {
|
||||
drafts.push(...liveValues(window.localStorage.getItem(storageKey), now));
|
||||
}
|
||||
}
|
||||
return drafts;
|
||||
} catch {
|
||||
// Storage unavailable - no draft can be offered either.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { DRAFT_PREFIX } from "./draft-storage";
|
||||
import { useFormDraft } from "./use-form-draft";
|
||||
@@ -10,6 +10,35 @@ describe("useFormDraft", () => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("clear() also drops the write still waiting from the last change", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { result, rerender } = renderHook(
|
||||
({ values }) => useFormDraft("test-form", values, true),
|
||||
{ initialProps: { values: { a: "x" } } },
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(700));
|
||||
const changed = { a: "xy" };
|
||||
rerender({ values: changed });
|
||||
|
||||
// Saved less than 600 ms after the last change; the form stays mounted
|
||||
// (a stand round shows its next-colony panel).
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
act(() => result.current.clear());
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => result.current.flush());
|
||||
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull();
|
||||
|
||||
// A real change after that is kept again.
|
||||
rerender({ values: { a: "new entry" } });
|
||||
act(() => vi.advanceTimersByTime(700));
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY)!).values).toEqual({ a: "new entry" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("flush writes immediately", () => {
|
||||
const { result } = renderHook(() => useFormDraft("test-form", { a: "x" }, true));
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
|
||||
import { DRAFT_PREFIX as PREFIX } from "./draft-storage";
|
||||
import { DRAFT_MAX_AGE_MS as MAX_AGE_MS, DRAFT_PREFIX as PREFIX } from "./draft-storage";
|
||||
const SAVE_DELAY_MS = 600;
|
||||
// Drafts older than this are stale (the situation at the hive has changed).
|
||||
const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface StoredDraft<T> {
|
||||
savedAt: number;
|
||||
@@ -81,13 +79,20 @@ export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): For
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
// The entry a save has just cleared the draft for: never written back, even
|
||||
// though the form may stay mounted (a stand round shows its next panel).
|
||||
// The next change writes again.
|
||||
const cleared = useRef<{ values: T } | null>(null);
|
||||
const pendingWrite = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty || pending) return;
|
||||
if (!dirty || pending || cleared.current?.values === values) return;
|
||||
const timer = window.setTimeout(() => write(key, values), SAVE_DELAY_MS);
|
||||
pendingWrite.current = timer;
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [key, values, dirty, pending]);
|
||||
|
||||
// The latest state for `flush`, which must stay a stable callback.
|
||||
// The latest state for `flush` and `clear`, which must stay stable callbacks.
|
||||
const latest = useRef({ key, values, dirty, pending });
|
||||
useEffect(() => {
|
||||
latest.current = { key, values, dirty, pending };
|
||||
@@ -96,7 +101,7 @@ export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): For
|
||||
// An offered earlier draft is not overwritten: it is still the user's choice.
|
||||
const flush = useCallback(() => {
|
||||
const current = latest.current;
|
||||
if (!current.dirty || current.pending) return;
|
||||
if (!current.dirty || current.pending || cleared.current?.values === current.values) return;
|
||||
write(current.key, current.values);
|
||||
}, []);
|
||||
|
||||
@@ -111,7 +116,12 @@ export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): For
|
||||
setPending(null);
|
||||
}, [key]);
|
||||
|
||||
const clear = useCallback(() => remove(key), [key]);
|
||||
const clear = useCallback(() => {
|
||||
// A write still waiting from the last change would bring the draft back.
|
||||
window.clearTimeout(pendingWrite.current);
|
||||
cleared.current = { values: latest.current.values };
|
||||
remove(key);
|
||||
}, [key]);
|
||||
|
||||
return { pending, restore, discard, clear, flush };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
fetchReportingOutcome,
|
||||
fetchWithTimeout,
|
||||
isNetworkError,
|
||||
BULK_WRITE_TIMEOUT_MS,
|
||||
timeoutFor,
|
||||
} from "./fetch-with-timeout";
|
||||
|
||||
@@ -36,6 +37,32 @@ describe("fetch-with-timeout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("a bulk POST waits as long as the server transaction may take, other keyed writes 4 s", async () => {
|
||||
const fetchMock = vi.fn(hangingFetch);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let bulkError: unknown = null;
|
||||
let singleError: unknown = null;
|
||||
fetchReportingOutcome("https://api.example/treatments/bulk", {
|
||||
method: "POST",
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}).catch((error: unknown) => {
|
||||
bulkError = error;
|
||||
});
|
||||
fetchReportingOutcome("https://api.example/treatments", {
|
||||
method: "POST",
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}).catch((error: unknown) => {
|
||||
singleError = error;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_TIMEOUT_MS + 100);
|
||||
expect(singleError).toMatchObject({ name: "TimeoutError" });
|
||||
expect(bulkError).toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(BULK_WRITE_TIMEOUT_MS);
|
||||
expect(bulkError).toMatchObject({ name: "TimeoutError" });
|
||||
});
|
||||
|
||||
it("rejects with TimeoutError after the timeout", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(hangingFetch));
|
||||
const pending = fetchWithTimeout("/x", { timeoutMs: 4_000 });
|
||||
|
||||
@@ -2,6 +2,8 @@ import { reportRequestOutcome } from "../offline/connectivity";
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 4_000; // GET and writes with Idempotency-Key
|
||||
export const NON_IDEMPOTENT_WRITE_TIMEOUT_MS = 15_000;
|
||||
/** A bulk POST writes up to 100 records in one server transaction (15 s allowed there). */
|
||||
export const BULK_WRITE_TIMEOUT_MS = 15_000;
|
||||
export const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
export const DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
|
||||
@@ -12,6 +14,12 @@ export function timeoutFor(method: string, headers: HeadersInit | undefined): nu
|
||||
return NON_IDEMPOTENT_WRITE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function isBulkPost(input: RequestInfo | URL, method: string | undefined): boolean {
|
||||
if ((method ?? "GET").toUpperCase() !== "POST") return false;
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return /\/bulk(?:[?#]|$)/.test(url);
|
||||
}
|
||||
|
||||
// Statuses that must not carry a body when a Response is rebuilt.
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
@@ -86,8 +94,11 @@ export async function fetchReportingOutcome(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit & { timeoutMs: number },
|
||||
): Promise<Response> {
|
||||
const timeoutMs = isBulkPost(input, init.method)
|
||||
? Math.max(init.timeoutMs, BULK_WRITE_TIMEOUT_MS)
|
||||
: init.timeoutMs;
|
||||
try {
|
||||
const response = await fetchWithTimeout(input, init);
|
||||
const response = await fetchWithTimeout(input, { ...init, timeoutMs });
|
||||
reportRequestOutcome("reached-server");
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,7 +42,10 @@ function textOrNull(text: string | null): string | null {
|
||||
* What the last inspection recorded for a field, in the form's own shape;
|
||||
* `null` when it was not recorded then (nothing to offer or take over).
|
||||
*/
|
||||
export function previousValueOf(last: Inspection, field: TakeoverField): TakeoverValue | null {
|
||||
export function previousValueOf(
|
||||
last: Omit<Inspection, "userId">,
|
||||
field: TakeoverField,
|
||||
): TakeoverValue | null {
|
||||
switch (field) {
|
||||
case "broodFrames":
|
||||
return countText(last.broodFrames);
|
||||
@@ -106,7 +109,7 @@ function withField(
|
||||
*/
|
||||
export function takeOverAll(
|
||||
values: InspectionFormValues,
|
||||
last: Inspection,
|
||||
last: Omit<Inspection, "userId">,
|
||||
): { values: InspectionFormValues; taken: TakeoverField[] } {
|
||||
let next = values;
|
||||
const taken: TakeoverField[] = [];
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { fetchColonyInspections } from "./api";
|
||||
import { useLastInspection } from "./use-last-inspection";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import {
|
||||
offlinePack,
|
||||
packColony,
|
||||
packInspection,
|
||||
storedPack,
|
||||
} from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import type { Inspection } from "./types";
|
||||
|
||||
vi.mock("./api", () => ({ fetchColonyInspections: vi.fn() }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||
listPacks: vi.fn(),
|
||||
}));
|
||||
|
||||
function day(isoDay: string): string {
|
||||
return `${isoDay}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
function renderLastInspection(client: QueryClient) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
return renderHook(() => useLastInspection("colony-1"), { wrapper });
|
||||
}
|
||||
|
||||
function newClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
describe("useLastInspection", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColonyInspections).mockReset();
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("takes the newest inspection the server has", async () => {
|
||||
vi.mocked(fetchColonyInspections).mockResolvedValue([
|
||||
{ ...packInspection("old", "colony-1", day("2026-09-01")), userId: "u" },
|
||||
{ ...packInspection("new", "colony-1", day("2026-10-01")), userId: "u" },
|
||||
]);
|
||||
|
||||
const { result } = renderLastInspection(newClient());
|
||||
|
||||
await waitFor(() => expect(result.current.inspection?.id).toBe("new"));
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the last inspection from the loaded pack while offline", async () => {
|
||||
vi.mocked(fetchColonyInspections).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
vi.mocked(listPacks).mockResolvedValue([
|
||||
storedPack(
|
||||
offlinePack({
|
||||
colonies: [packColony("colony-1", "A1")],
|
||||
inspections: [
|
||||
packInspection("p-old", "colony-1", day("2026-09-20")),
|
||||
packInspection("p-new", "colony-1", day("2026-10-02")),
|
||||
packInspection("other", "colony-2", day("2026-10-05")),
|
||||
],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const { result } = renderLastInspection(newClient());
|
||||
|
||||
await waitFor(() => expect(result.current.inspection?.id).toBe("p-new"));
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the colony page's inspections at once while it asks the server again", async () => {
|
||||
vi.mocked(fetchColonyInspections).mockReturnValue(new Promise<Inspection[]>(() => undefined));
|
||||
const client = newClient();
|
||||
client.setQueryData<Inspection[]>(
|
||||
["colonies", "colony-1", "inspections"],
|
||||
[{ ...packInspection("cached", "colony-1", day("2026-10-01")), userId: "u" }],
|
||||
);
|
||||
|
||||
const { result } = renderLastInspection(client);
|
||||
|
||||
expect(result.current.inspection?.id).toBe("cached");
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { inspectionsFromPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
import { fetchColonyInspections } from "./api";
|
||||
import type { Inspection } from "./types";
|
||||
|
||||
function latestOf(inspections: readonly Inspection[]): Inspection | null {
|
||||
/** What the hints read: an inspection from the server or from an offline pack. */
|
||||
export type LastInspection = Omit<Inspection, "userId">;
|
||||
|
||||
function latestOf(inspections: readonly LastInspection[]): LastInspection | null {
|
||||
return (
|
||||
[...inspections].sort(
|
||||
(a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt),
|
||||
@@ -13,17 +19,35 @@ function latestOf(inspections: readonly Inspection[]): Inspection | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The colony's most recent inspection, for the "Letztes Mal" hints. Shares
|
||||
* the colony page's inspections query, so it is usually already cached.
|
||||
* The colony's most recent inspection, for the "Letztes Mal" hints. Without
|
||||
* network it comes from the colony's offline pack. The colony page's
|
||||
* inspections, when cached, show at once while the server is asked; pack
|
||||
* data never goes into that shared cache (it holds only the last three).
|
||||
*/
|
||||
export function useLastInspection(colonyId: string): {
|
||||
inspection: Inspection | null;
|
||||
inspection: LastInspection | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const userId = useCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
const colonyInspectionsKey = ["colonies", colonyId, "inspections"] as const;
|
||||
const query = useQuery({
|
||||
queryKey: ["colonies", colonyId, "inspections"],
|
||||
queryFn: () => fetchColonyInspections(colonyId),
|
||||
queryKey: [...colonyInspectionsKey, "latest", userId],
|
||||
queryFn: (): Promise<LastInspection[]> => {
|
||||
const fromServer = () => fetchColonyInspections(colonyId);
|
||||
if (!userId) {
|
||||
return fromServer();
|
||||
}
|
||||
return withPackFallback<LastInspection[]>(
|
||||
fromServer,
|
||||
(packs) => inspectionsFromPacks(packs, colonyId),
|
||||
userId,
|
||||
);
|
||||
},
|
||||
placeholderData: () => queryClient.getQueryData<Inspection[]>(colonyInspectionsKey),
|
||||
enabled: colonyId !== "",
|
||||
// Offline the pack answers from this device: never wait for a network.
|
||||
networkMode: "always",
|
||||
});
|
||||
return {
|
||||
inspection: query.data ? latestOf(query.data) : null,
|
||||
|
||||
@@ -227,3 +227,151 @@ describe("offline queue database", () => {
|
||||
raw.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk entries in the offline queue", () => {
|
||||
const BULK_TREATMENT = {
|
||||
date: "2026-10-14T00:00:00.000Z",
|
||||
product: "Oxuvar",
|
||||
batchNumber: "Ch-123",
|
||||
colonyIds: ["c-1", "c-2", "c-3"],
|
||||
};
|
||||
const BULK_VARROA = {
|
||||
date: "2026-10-14T00:00:00.000Z",
|
||||
method: "NATURAL_MITE_FALL",
|
||||
entries: [
|
||||
{ colonyId: "c-1", miteCount: 4 },
|
||||
{ colonyId: "c-3", miteCount: 9 },
|
||||
],
|
||||
};
|
||||
const INVALID_COLONIES = {
|
||||
status: 400,
|
||||
message: "Einige Völker sind nicht (mehr) erfassbar",
|
||||
details: { invalidColonyIds: ["c-3"] },
|
||||
at: "2026-10-14T09:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
async function onlyBulkEntry() {
|
||||
const raw = await openDB(DB_NAME);
|
||||
const [entry] = await raw.getAll("pending-bulk");
|
||||
raw.close();
|
||||
return entry;
|
||||
}
|
||||
|
||||
it("queueBulk stores exactly one entry with kind, input, key and owner", async () => {
|
||||
const db = await loadDb();
|
||||
|
||||
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
|
||||
|
||||
expect(await onlyBulkEntry()).toEqual({
|
||||
id: 1,
|
||||
kind: "treatments",
|
||||
input: BULK_TREATMENT,
|
||||
idempotencyKey: "bulk-key-0000000001",
|
||||
ownerUserId: "user-1",
|
||||
queuedAt: expect.any(String),
|
||||
});
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
|
||||
});
|
||||
|
||||
it("lists a bulk entry under its own name", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("varroa", BULK_VARROA, "bulk-key-0000000001", "user-1");
|
||||
|
||||
const [item] = await db.listQueue("user-1");
|
||||
|
||||
expect(item).toMatchObject({
|
||||
store: "pending-bulk",
|
||||
label: "Varroamessung für mehrere Völker",
|
||||
});
|
||||
expect(item.resendWithout).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the rejected colonies by code from the loaded packs", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
|
||||
const raw = await openDB(DB_NAME);
|
||||
await raw.put("offline-packs", {
|
||||
apiaryId: "apiary-1",
|
||||
ownerUserId: "user-1",
|
||||
savedAt: "2026-10-14T08:00:00.000Z",
|
||||
pack: {
|
||||
apiary: { id: "apiary-1", name: "Waldrand" },
|
||||
colonies: [
|
||||
{ id: "c-1", code: "V-1", apiaryId: "apiary-1", status: "ACTIVE" },
|
||||
{ id: "c-3", code: "V-3", apiaryId: "apiary-1", status: "DISSOLVED" },
|
||||
],
|
||||
},
|
||||
});
|
||||
raw.close();
|
||||
const entry = await onlyBulkEntry();
|
||||
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
|
||||
|
||||
const [item] = await db.listQueue("user-1");
|
||||
|
||||
expect(item.resendWithout).toEqual({ colonyIds: ["c-3"], codes: ["V-3"] });
|
||||
});
|
||||
|
||||
it("offers no resend when every colony of the entry was rejected", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("treatments", { ...BULK_TREATMENT, colonyIds: ["c-3"] }, "bulk-key-0000000001", "user-1");
|
||||
const entry = await onlyBulkEntry();
|
||||
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
|
||||
|
||||
const [item] = await db.listQueue("user-1");
|
||||
|
||||
expect(item.resendWithout).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retryBulkWithout resends the remaining colonies with the same key and clears the rejection", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
|
||||
const entry = await onlyBulkEntry();
|
||||
await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES);
|
||||
|
||||
await db.retryBulkWithout(entry.id, ["c-3"]);
|
||||
|
||||
expect(await onlyBulkEntry()).toEqual({
|
||||
...entry,
|
||||
input: { ...BULK_TREATMENT, colonyIds: ["c-1", "c-2"] },
|
||||
lastError: undefined,
|
||||
});
|
||||
expect((await onlyBulkEntry()).idempotencyKey).toBe("bulk-key-0000000001");
|
||||
});
|
||||
|
||||
it("retryBulkWithout drops a varroa colony with its own count", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("varroa", BULK_VARROA, "bulk-key-0000000001", "user-1");
|
||||
const entry = await onlyBulkEntry();
|
||||
|
||||
await db.retryBulkWithout(entry.id, ["c-3"]);
|
||||
|
||||
expect((await onlyBulkEntry()).input).toEqual({
|
||||
...BULK_VARROA,
|
||||
entries: [{ colonyId: "c-1", miteCount: 4 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("retryBulkWithout removes an entry that would be left without colonies", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("treatments", { ...BULK_TREATMENT, colonyIds: ["c-3"] }, "bulk-key-0000000001", "user-1");
|
||||
const entry = await onlyBulkEntry();
|
||||
|
||||
await db.retryBulkWithout(entry.id, ["c-3"]);
|
||||
|
||||
expect(await onlyBulkEntry()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discardQueueItem deletes a bulk entry", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueBulk("feedings", BULK_TREATMENT, "bulk-key-0000000001", "user-1");
|
||||
const [item] = await db.listQueue("user-1");
|
||||
|
||||
await db.discardQueueItem(item);
|
||||
|
||||
expect(await onlyBulkEntry()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import { CreateFeedingInput } from "@/lib/feedings/types";
|
||||
import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
|
||||
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
|
||||
import { CreateHarvestInput } from "@/lib/harvests/types";
|
||||
import {
|
||||
colonyIdsOfBulkInput,
|
||||
invalidColonyIdsOfBody,
|
||||
withoutColonies,
|
||||
} from "@/lib/bulk/bulk-input";
|
||||
|
||||
/** What each single-record queue (one per quick-capture form) holds. */
|
||||
export interface RecordQueueInputs {
|
||||
@@ -191,6 +196,15 @@ function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A new queue entry; the store gives it its id when it is added. */
|
||||
export function newPendingEntry<T>(
|
||||
input: T,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Omit<PendingEntry<T>, "id"> {
|
||||
return { input, idempotencyKey, ownerUserId, queuedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
async function queue<Store extends RecordStoreName>(
|
||||
store: Store,
|
||||
input: RecordQueueInputs[Store],
|
||||
@@ -198,13 +212,7 @@ async function queue<Store extends RecordStoreName>(
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const entry: Omit<PendingEntry<RecordQueueInputs[Store]>, "id"> = {
|
||||
input,
|
||||
idempotencyKey,
|
||||
ownerUserId,
|
||||
queuedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.add(store, entry as never);
|
||||
await db.add(store, newPendingEntry(input, idempotencyKey, ownerUserId) as never);
|
||||
}
|
||||
|
||||
export function queueInspection(
|
||||
@@ -255,6 +263,27 @@ export function queueHarvest(
|
||||
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry for several colonies. The key belongs to this exact selection of
|
||||
* colonies: it is stored with the entry and sent again unchanged.
|
||||
*/
|
||||
export async function queueBulk(
|
||||
kind: BulkKind,
|
||||
input: unknown,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const entry: Omit<PendingBulkEntry, "id"> = {
|
||||
kind,
|
||||
input,
|
||||
idempotencyKey,
|
||||
ownerUserId,
|
||||
queuedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.add("pending-bulk", entry as PendingBulkEntry);
|
||||
}
|
||||
|
||||
/** Every entry of one record queue, oldest first, of every owner. */
|
||||
export async function getPendingEntries<Store extends RecordStoreName>(
|
||||
store: Store,
|
||||
@@ -272,6 +301,18 @@ export async function getPendingEntry<Store extends RecordStoreName>(
|
||||
return (await db.get(store, id)) as PendingEntry<RecordQueueInputs[Store]> | undefined;
|
||||
}
|
||||
|
||||
/** Every bulk entry, oldest first, of every owner. */
|
||||
export async function getBulkEntries(): Promise<PendingBulkEntry[]> {
|
||||
const db = await getDb();
|
||||
return db.getAll("pending-bulk");
|
||||
}
|
||||
|
||||
/** One bulk entry as it is stored right now, or undefined once it is gone. */
|
||||
export async function getBulkEntry(id: number): Promise<PendingBulkEntry | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get("pending-bulk", id);
|
||||
}
|
||||
|
||||
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete(store, id);
|
||||
@@ -300,6 +341,32 @@ export function isForeignEntry(entry: { ownerUserId?: string }, currentUserId: s
|
||||
return entry.ownerUserId !== undefined && entry.ownerUserId !== currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Ohne diese Völker erneut senden“: takes the colonies out of a queued bulk
|
||||
* entry and clears its rejection, so the next sync run sends the rest. The
|
||||
* key stays: the server stored nothing for the rejected try and released it.
|
||||
* An entry left without any colony is removed.
|
||||
*/
|
||||
export async function retryBulkWithout(
|
||||
entryId: number,
|
||||
colonyIds: readonly string[],
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("pending-bulk", "readwrite");
|
||||
const entry = await tx.store.get(entryId);
|
||||
if (entry) {
|
||||
const input = withoutColonies(entry.kind, entry.input, colonyIds);
|
||||
if (colonyIdsOfBulkInput(entry.kind, input).length === 0) {
|
||||
await tx.store.delete(entryId);
|
||||
} else {
|
||||
const cleared: PendingBulkEntry = { ...entry, input };
|
||||
delete cleared.lastError;
|
||||
await tx.store.put(cleared);
|
||||
}
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
type QueuedValue = PendingEntry<unknown> | PendingBulkEntry;
|
||||
|
||||
interface QueuedRecord {
|
||||
@@ -366,6 +433,11 @@ export interface QueueItem {
|
||||
queuedAt: string;
|
||||
lastError?: QueueError;
|
||||
photoCount?: number;
|
||||
/**
|
||||
* A rejected bulk entry that can go out without the colonies the server
|
||||
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
|
||||
*/
|
||||
resendWithout?: { colonyIds: string[]; codes: string[] };
|
||||
}
|
||||
|
||||
function labelOf({ store, entry }: QueuedRecord): string {
|
||||
@@ -375,6 +447,27 @@ function labelOf({ store, entry }: QueuedRecord): string {
|
||||
return RECORD_LABELS[store];
|
||||
}
|
||||
|
||||
async function resendWithoutOf(
|
||||
db: IDBPDatabase<OfflineDB>,
|
||||
entry: PendingBulkEntry,
|
||||
): Promise<QueueItem["resendWithout"]> {
|
||||
const all = colonyIdsOfBulkInput(entry.kind, entry.input);
|
||||
const rejected = invalidColonyIdsOfBody(entry.lastError?.details).filter((id) =>
|
||||
all.includes(id),
|
||||
);
|
||||
if (rejected.length === 0 || rejected.length === all.length) {
|
||||
return undefined;
|
||||
}
|
||||
const packs = await db.getAll("offline-packs");
|
||||
const codeOf = new Map(
|
||||
packs.flatMap((stored) => stored.pack.colonies.map((colony) => [colony.id, colony.code])),
|
||||
);
|
||||
return {
|
||||
colonyIds: rejected,
|
||||
codes: rejected.flatMap((id) => codeOf.get(id) ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in user's waiting and rejected entries, oldest first. */
|
||||
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
const db = await getDb();
|
||||
@@ -389,6 +482,10 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
queuedAt: entry.queuedAt,
|
||||
...(entry.lastError ? { lastError: entry.lastError } : {}),
|
||||
};
|
||||
if (store === "pending-bulk") {
|
||||
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
|
||||
return resendWithout ? { ...item, resendWithout } : item;
|
||||
}
|
||||
if (store !== "pending-inspections" || !entry.idempotencyKey) {
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("idb", () => {
|
||||
// The photos that follow a queued inspection are covered in lib/photos/store.spec.ts.
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
resolveQueuedInspection: vi.fn(async () => undefined),
|
||||
finishHandOvers: vi.fn(async () => 0),
|
||||
uploadReadyPhotos: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import { withPackFallback } from "./pack-fallback";
|
||||
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ listPacks: vi.fn() }));
|
||||
|
||||
const PACKS = [storedPack(offlinePack({ apiaryId: "apiary-1", apiaryName: "Obstwiese" }))];
|
||||
|
||||
const apiaryNames = vi.fn((packs: typeof PACKS) => packs.map((pack) => pack.pack.apiary.name));
|
||||
|
||||
describe("withPackFallback", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue(PACKS);
|
||||
apiaryNames.mockClear();
|
||||
});
|
||||
|
||||
it("answers from the server whenever the server answers", async () => {
|
||||
const result = await withPackFallback(async () => ["vom Server"], apiaryNames, "user-1");
|
||||
|
||||
expect(result).toEqual(["vom Server"]);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the pack on a network error", async () => {
|
||||
const result = await withPackFallback(
|
||||
() => Promise.reject(new TypeError("Failed to fetch")),
|
||||
apiaryNames,
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result).toEqual(["Obstwiese"]);
|
||||
expect(listPacks).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("falls back to the pack when the request timed out", async () => {
|
||||
const timeout = new DOMException("Zeitüberschreitung der Anfrage", "TimeoutError");
|
||||
|
||||
const result = await withPackFallback(() => Promise.reject(timeout), apiaryNames, "user-1");
|
||||
|
||||
expect(result).toEqual(["Obstwiese"]);
|
||||
});
|
||||
|
||||
it("rethrows an ApiError instead of showing pack data", async () => {
|
||||
for (const status of [403, 404, 500]) {
|
||||
const rejection = new ApiError(status, "Nein");
|
||||
|
||||
await expect(
|
||||
withPackFallback(() => Promise.reject(rejection), apiaryNames, "user-1"),
|
||||
).rejects.toBe(rejection);
|
||||
}
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
expect(apiaryNames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rethrows when the pack has nothing", async () => {
|
||||
const offline = new TypeError("Failed to fetch");
|
||||
|
||||
await expect(
|
||||
withPackFallback(() => Promise.reject(offline), () => null, "user-1"),
|
||||
).rejects.toBe(offline);
|
||||
});
|
||||
|
||||
it("rethrows the network error when the packs cannot be read", async () => {
|
||||
const offline = new TypeError("Failed to fetch");
|
||||
vi.mocked(listPacks).mockRejectedValue(new Error("IndexedDB gesperrt"));
|
||||
|
||||
await expect(
|
||||
withPackFallback(() => Promise.reject(offline), apiaryNames, "user-1"),
|
||||
).rejects.toBe(offline);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import type { StoredPack } from "./db";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
|
||||
/**
|
||||
* The server's answer - or, only when the network failed, what the user's
|
||||
* offline packs say. A server refusal (ApiError: 403, 404, 400 ...) is never
|
||||
* covered up with pack data: the server knows better than an old pack.
|
||||
* When the packs have nothing (`fromPacks` returns null) or cannot be read,
|
||||
* the network error is passed on, so the caller shows "not loaded".
|
||||
*
|
||||
* Queries built on this read local storage when offline: give them
|
||||
* `networkMode: "always"`, or a paused retry would hide the fallback.
|
||||
*/
|
||||
export async function withPackFallback<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
fromPacks: (packs: StoredPack[]) => T | null,
|
||||
ownerUserId: string,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fetcher();
|
||||
} catch (error) {
|
||||
if (!isNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const fallback = await listPacks(ownerUserId).then(fromPacks, () => null);
|
||||
if (fallback === null) {
|
||||
throw error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
entriesNotSent,
|
||||
entriesRejected,
|
||||
entriesSynced,
|
||||
entriesWaiting,
|
||||
foreignEntriesWaiting,
|
||||
photosAttached,
|
||||
photosNotTakenOver,
|
||||
sessionExpiredWithEntries,
|
||||
} from "./queue-texts";
|
||||
|
||||
describe("queue texts", () => {
|
||||
@@ -14,6 +17,7 @@ describe("queue texts", () => {
|
||||
expect(foreignEntriesWaiting(1)).toBe("1 Eintrag eines anderen Kontos wartet");
|
||||
expect(entriesSynced(1)).toBe("1 Eintrag synchronisiert");
|
||||
expect(photosAttached(1)).toBe("+ 1 Foto");
|
||||
expect(photosNotTakenOver(1)).toBe("1 Foto wurde nicht übernommen und verworfen");
|
||||
});
|
||||
|
||||
it("uses the plural for two entries", () => {
|
||||
@@ -22,5 +26,21 @@ describe("queue texts", () => {
|
||||
expect(foreignEntriesWaiting(2)).toBe("2 Einträge eines anderen Kontos warten");
|
||||
expect(entriesSynced(2)).toBe("2 Einträge synchronisiert");
|
||||
expect(photosAttached(2)).toBe("+ 2 Fotos");
|
||||
expect(photosNotTakenOver(2)).toBe("2 Fotos wurden nicht übernommen und verworfen");
|
||||
});
|
||||
|
||||
it("says how many entries are not sent yet when signing out", () => {
|
||||
expect(entriesNotSent(1)).toBe("1 Eintrag ist noch nicht gesendet.");
|
||||
expect(entriesNotSent(3)).toBe("3 Einträge sind noch nicht gesendet.");
|
||||
});
|
||||
|
||||
it("tells an expired session apart from the entries that still wait", () => {
|
||||
expect(sessionExpiredWithEntries(0)).toBe("Sitzung abgelaufen – bitte neu anmelden.");
|
||||
expect(sessionExpiredWithEntries(1)).toBe(
|
||||
"Sitzung abgelaufen – bitte neu anmelden. 1 Eintrag wartet und wird danach gesendet.",
|
||||
);
|
||||
expect(sessionExpiredWithEntries(4)).toBe(
|
||||
"Sitzung abgelaufen – bitte neu anmelden. 4 Einträge warten und werden danach gesendet.",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user