diff --git a/frontend/src/components/__tests__/UserLimitsCard.test.tsx b/frontend/src/components/__tests__/UserLimitsCard.test.tsx new file mode 100644 index 00000000..8ffe1d90 --- /dev/null +++ b/frontend/src/components/__tests__/UserLimitsCard.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import UserLimitsCard from "@/components/report/UserLimitsCard"; +import type { UserLimits } from "@/lib/api-client/models"; + +const t = (s: string) => s; + +const soy = { + feed_id: "c5624c59", + feed_name: "Soybean meal - Seed - Cake stage - Wet season", + display_name: "Soybean meal (localized)", + min_kg_asfed: null, + max_kg_asfed: 2, + result_kg_asfed: 1.88, +}; +const fat = { + key: "ee_max", + label: "Fat Content", + display_label: "Fat Content (localized)", + direction: "max" as const, + value: 3, + default: 7, + unit: "% DM", +}; + +const cells = (name: string) => + within(screen.getByRole("table", { name })) + .getAllByRole("cell") + .map((c) => c.textContent); + +describe("UserLimitsCard", () => { + it("shows both tables, the localized names and the footnote", () => { + render(); + expect(screen.getByText("Limits You Set")).toBeInTheDocument(); + expect(cells("Feed inclusion limits")).toEqual([ + "Soybean meal (localized)", + "—", + "2.00", + "1.88", + ]); + expect(cells("Custom Diet Limits")).toEqual(["Fat Content (localized)", "≤ 3", "≤ 7", "% DM"]); + expect( + screen.getByText("These limits were set by the user and shape the diet above.") + ).toBeInTheDocument(); + }); + + it("shows only the table that has rows", () => { + render(); + expect(screen.getByRole("table", { name: "Custom Diet Limits" })).toBeInTheDocument(); + expect(screen.queryByRole("table", { name: "Feed inclusion limits" })).toBeNull(); + }); + + it.each<[string, UserLimits | null | undefined]>([ + ["absent", undefined], + ["null", null], + ["empty lists", { inclusion_limits: [], diet_limits: [] }], + ])("renders nothing when user_limits is %s", (_, userLimits) => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows — for null amounts (no diet found) and falls back to the English names", () => { + render( + + ); + expect(cells("Feed inclusion limits")).toEqual([soy.feed_name, "0.50", "2.00", "—"]); + expect(cells("Custom Diet Limits")).toEqual(["Fat Content", "≤ 3", "—", "% DM"]); + }); + + it("uses ≥ for a min limit (ndf_for_min)", () => { + render( + + ); + expect(cells("Custom Diet Limits")).toEqual(["Forage NDF", "≥ 19", "≥ 15", "% DM"]); + }); +}); diff --git a/frontend/src/components/report/RecommendationSections.tsx b/frontend/src/components/report/RecommendationSections.tsx index 1a311624..66a3a551 100644 --- a/frontend/src/components/report/RecommendationSections.tsx +++ b/frontend/src/components/report/RecommendationSections.tsx @@ -13,6 +13,7 @@ import { } from "@/components/Icons"; import { COLORS } from "@/lib/colors"; import EnvironmentalImpactSection from "./EnvironmentalImpactSection"; +import UserLimitsCard from "./UserLimitsCard"; import type { CostEffectiveDiet } from "@/lib/api"; import type { RecommendationResponse } from "@/lib/api-client/models"; @@ -311,6 +312,10 @@ export default function RecommendationSections({ )} + {/* Limits You Set (N3) — shown even when the diet is hidden, so the + user can see which limits may have made it impossible. */} + + {/* Environmental Impact — also hidden when diet rating is ERROR_*. */} {showSolutionSections && ( diff --git a/frontend/src/components/report/UserLimitsCard.tsx b/frontend/src/components/report/UserLimitsCard.tsx new file mode 100644 index 00000000..81031d61 --- /dev/null +++ b/frontend/src/components/report/UserLimitsCard.tsx @@ -0,0 +1,107 @@ +"use client"; + +// N3 "Limits You Set": the feed-card Min / Max and custom diet limits that +// shaped this run, as in the PDF's section of the same name. Renders nothing +// when the backend sent no `user_limits` (none set, or a pre-PR #115 report). +import type { CSSProperties } from "react"; +import { SCard } from "@/components/report/parts"; +import { IcViolatedParameter } from "@/components/Icons"; +import { COLORS } from "@/lib/colors"; +import type { UserDietLimit, UserLimits } from "@/lib/api-client/models"; + +const DASH = "—"; + +const amount = (n: number | null | undefined) => + n === null || n === undefined || !Number.isFinite(n) ? DASH : n.toFixed(2); + +// "max" → "≤ 3", "min" → "≥ 30"; "—" when the value is missing. +const bound = (direction: UserDietLimit["direction"], n: number | null | undefined) => + n === null || n === undefined || !Number.isFinite(n) + ? DASH + : `${direction === "min" ? "≥" : "≤"} ${n}`; + +const headCell: CSSProperties = { + color: COLORS.darkSilver, + fontSize: 12, + letterSpacing: 0.5, +}; +const bodyCell: CSSProperties = { color: COLORS.raisinBlack, fontSize: 14 }; +const COLS = "minmax(0,1.6fr) minmax(0,0.8fr) minmax(0,0.8fr) minmax(0,1fr)"; + +function Row({ cells, head }: { cells: string[]; head?: boolean }) { + return ( +
+ {cells.map((c, i) => ( + + {c} + + ))} +
+ ); +} + +export default function UserLimitsCard({ + userLimits, + t, +}: { + userLimits: UserLimits | null | undefined; + t: (source: string) => string; +}) { + const inclusion = userLimits?.inclusion_limits ?? []; + const diet = userLimits?.diet_limits ?? []; + if (inclusion.length === 0 && diet.length === 0) return null; + + return ( + }> + {inclusion.length > 0 && ( +
+ + {inclusion.map((row, i) => ( + + ))} +
+ )} + {diet.length > 0 && ( +
+ + {diet.map((row, i) => ( + + ))} +
+ )} +

+ {t("These limits were set by the user and shape the diet above.")} +

+
+ ); +} diff --git a/frontend/src/lib/api-client/models.ts b/frontend/src/lib/api-client/models.ts index 45461e6b..e8240ec0 100644 --- a/frontend/src/lib/api-client/models.ts +++ b/frontend/src/lib/api-client/models.ts @@ -506,6 +506,39 @@ export interface RecommendationResponse extends ReportExtras { report_info: ReportInfo | null; solution_summary: SolutionSummary | null; total_diet_cost: number | null; + // N3: the limits the user set for this run (feed-card Min / Max and custom + // diet limits), as in the PDF's "Limits You Set". Absent when none was set, + // and on reports computed before backend PR #115. + user_limits?: UserLimits | null; +} + +export interface UserInclusionLimit { + feed_id: string; + feed_name: string; + // Already in the report's language. + display_name?: string | null; + // kg/day as fed; null = no limit on that side. + min_kg_asfed: number | null; + max_kg_asfed: number | null; + // Amount in the diet; null when no diet was found. + result_kg_asfed: number | null; +} + +export interface UserDietLimit { + key: string; + label: string; + // Already in the report's language. + display_label?: string | null; + // "max" → ≤, "min" → ≥ (only ndf_for_min is a min). + direction: "max" | "min"; + value: number | null; + default: number | null; + unit: string; +} + +export interface UserLimits { + inclusion_limits?: UserInclusionLimit[] | null; + diet_limits?: UserDietLimit[] | null; } export interface Country { diff --git a/frontend/src/lib/data/ui-labels.xlsx b/frontend/src/lib/data/ui-labels.xlsx index 4ae9cca6..01ff9ca5 100644 Binary files a/frontend/src/lib/data/ui-labels.xlsx and b/frontend/src/lib/data/ui-labels.xlsx differ