Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions frontend/src/components/__tests__/UserLimitsCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<UserLimitsCard userLimits={{ inclusion_limits: [soy], diet_limits: [fat] }} t={t} />);
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(<UserLimitsCard userLimits={{ diet_limits: [fat] }} t={t} />);
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(<UserLimitsCard userLimits={userLimits} t={t} />);
expect(container).toBeEmptyDOMElement();
});

it("shows — for null amounts (no diet found) and falls back to the English names", () => {
render(
<UserLimitsCard
userLimits={{
inclusion_limits: [
{ ...soy, display_name: null, min_kg_asfed: 0.5, result_kg_asfed: null },
],
diet_limits: [{ ...fat, display_label: null, default: null }],
}}
t={t}
/>
);
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(
<UserLimitsCard
userLimits={{
diet_limits: [
{
key: "ndf_for_min",
label: "Forage NDF",
direction: "min",
value: 19,
default: 15,
unit: "% DM",
},
],
}}
t={t}
/>
);
expect(cells("Custom Diet Limits")).toEqual(["Forage NDF", "≥ 19", "≥ 15", "% DM"]);
});
});
5 changes: 5 additions & 0 deletions frontend/src/components/report/RecommendationSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -311,6 +312,10 @@ export default function RecommendationSections({
</SCard>
)}

{/* Limits You Set (N3) — shown even when the diet is hidden, so the
user can see which limits may have made it impossible. */}
<UserLimitsCard userLimits={recReport.user_limits} t={t} />

{/* Environmental Impact — also hidden when diet rating is ERROR_*. */}
{showSolutionSections && (
<EnvironmentalImpactSection recReport={recReport} reportCtx={reportCtx} fmt={fmt} t={t} />
Expand Down
107 changes: 107 additions & 0 deletions frontend/src/components/report/UserLimitsCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="row"
className="grid items-center py-3 px-2"
style={{
gridTemplateColumns: COLS,
borderBottom: `1px solid ${head ? COLORS.webDivider : COLORS.americanDiamond}`,
}}
>
{cells.map((c, i) => (
<span
key={i}
role={head ? "columnheader" : "cell"}
className={`${head ? "font-bold uppercase" : i === 0 ? "font-bold" : ""} ${
i === 0 ? "" : "text-center"
}`}
style={head ? headCell : bodyCell}
>
{c}
</span>
))}
</div>
);
}

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 (
<SCard title={t("Limits You Set")} icon={<IcViolatedParameter />}>
{inclusion.length > 0 && (
<div role="table" aria-label={t("Feed inclusion limits")} className="mb-2">
<Row head cells={[t("Feed"), t("Min"), t("Max"), t("In diet (kg/day)")]} />
{inclusion.map((row, i) => (
<Row
key={`${row.feed_id}-${i}`}
cells={[
row.display_name || row.feed_name,
amount(row.min_kg_asfed),
amount(row.max_kg_asfed),
amount(row.result_kg_asfed),
]}
/>
))}
</div>
)}
{diet.length > 0 && (
<div role="table" aria-label={t("Custom Diet Limits")} className="mb-2">
<Row head cells={[t("Parameter"), t("Your limit"), t("Default"), t("Unit")]} />
{diet.map((row, i) => (
<Row
key={`${row.key}-${i}`}
cells={[
row.display_label || row.label,
bound(row.direction, row.value),
bound(row.direction, row.default),
row.unit,
]}
/>
))}
</div>
)}
<p className="px-2 pb-3" style={{ color: COLORS.darkSilver, fontSize: 12 }}>
{t("These limits were set by the user and shape the diet above.")}
</p>
</SCard>
);
}
33 changes: 33 additions & 0 deletions frontend/src/lib/api-client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Binary file modified frontend/src/lib/data/ui-labels.xlsx
Binary file not shown.
Loading