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