diff --git a/frontend/src/app/(main)/admin/feeds/page.tsx b/frontend/src/app/(main)/admin/feeds/page.tsx index 9f3cafc5..b6f6586e 100644 --- a/frontend/src/app/(main)/admin/feeds/page.tsx +++ b/frontend/src/app/(main)/admin/feeds/page.tsx @@ -141,8 +141,17 @@ export default function AdminFeedsPage() { loadAll(); }, [loadAll]); + // N7: a feed name is unique per country, not library-wide, so the list + // can be narrowed to one country on screen ("" = all). + const [countryFilter, setCountryFilter] = useState(""); + const countryNames = Array.from(new Set(countries.map((c) => c.name).filter(Boolean))).sort( + (a, b) => a.localeCompare(b) + ); + // Match Android FragmentFeed search: filter by feed name only. const filteredFeeds = feeds.filter((f) => { + if (countryFilter && (f.fd_country_name ?? "").toLowerCase() !== countryFilter.toLowerCase()) + return false; if (!searchQuery) return true; return f.fd_name?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false; }); @@ -386,10 +395,13 @@ export default function AdminFeedsPage() { {t("Management")}

- {/* ── FEEDS TAB ── (matches Android fragment_feed.xml: search-by-name only, - no filter dropdowns; FAB for add) */} + {/* ── FEEDS TAB ── (Android fragment_feed.xml: search by name; FAB for + add) plus a country filter (N7) */} {tab === "feeds" && ( } + {countryChange.dialog} + {/* Simulation History Bottom-Sheet Modal */} {showHistoryModal && ( { const next = prev.map((row) => @@ -411,6 +417,7 @@ export default function FeedSelectionPage() { onUpdate={updateItem} onDelete={deleteItem} taxonomyLabels={taxonomyLabels} + isFeedTaken={(feedUuid, rowId) => isFeedOnOtherCard(items, feedUuid, rowId)} /> ))} diff --git a/frontend/src/app/__tests__/admin-feeds.test.tsx b/frontend/src/app/__tests__/admin-feeds.test.tsx index df1fa299..8d4e7413 100644 --- a/frontend/src/app/__tests__/admin-feeds.test.tsx +++ b/frontend/src/app/__tests__/admin-feeds.test.tsx @@ -678,3 +678,56 @@ describe("Admin Feeds — UI-label translation (preferred_language: hi)", () => expect(screen.getByRole("button", { name: "Feed Type" })).toBeInTheDocument(); }); }); + +// ─── N7: the same feed name in two countries ───────────────────────────────── + +describe("Admin Feeds — feed country (N7)", () => { + const kenyaX = { ...sampleFeed, feed_id: "x-ke", fd_name: "X", fd_country_name: "Kenya" }; + const indiaX = { ...sampleFeed, feed_id: "x-in", fd_name: "X", fd_country_name: "India" }; + const twoCountries = [ + ...countries, + { id: "2", name: "Kenya", country_code: "KE", currency: "KES" }, + ]; + + async function openFeeds() { + resolveLoadAll({ feeds: [kenyaX, indiaX], countriesData: twoCountries }); + render(); + await waitForLoaded(); + fireEvent.click(screen.getByRole("button", { name: "Feed" })); + await waitFor(() => expect(screen.getAllByText("X")).toHaveLength(2)); + } + const cardOf = (country: string) => + screen + .getAllByText("X") + .map((n) => n.closest(".mx-3") as HTMLElement) + .find((c) => within(c).queryByText(country)) as HTMLElement; + + it("each card shows its feed's country", async () => { + await openFeeds(); + expect(cardOf("Kenya")).toBeTruthy(); + expect(cardOf("India")).toBeTruthy(); + expect(cardOf("Kenya")).not.toBe(cardOf("India")); + }); + + it("the country filter lists only that country's feeds", async () => { + await openFeeds(); + fireEvent.click(screen.getByRole("button", { name: /All Countries/ })); + fireEvent.click(await screen.findByRole("option", { name: "India" })); + + await waitFor(() => expect(screen.getAllByText("X")).toHaveLength(1)); + expect(cardOf("India")).toBeTruthy(); + expect(screen.queryByText("Kenya")).toBeNull(); + }); + + it("the delete dialog names the feed's country", async () => { + await openFeeds(); + fireEvent.click(within(cardOf("India")).getByRole("button", { name: "Delete feed" })); + + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByText( + '"X" (India) will be permanently deleted. This action cannot be undone.' + ) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/__tests__/cattle-info.test.tsx b/frontend/src/app/__tests__/cattle-info.test.tsx index 07567c00..13ab00fe 100644 --- a/frontend/src/app/__tests__/cattle-info.test.tsx +++ b/frontend/src/app/__tests__/cattle-info.test.tsx @@ -1466,3 +1466,148 @@ describe("Cattle Info — forward arrow to the last report (F47 / W3)", () => { expect(screen.queryByRole("button", { name: "Forward" })).toBeNull(); }); }); + +// ─── N8: changing the country clears the feed cards, after asking ────────── +describe("Cattle Info — country change clears the feed cards (N8)", () => { + const card = (id: string, feed: string | null): FeedItem => + ({ + id, + feed_type_id: null, + feed_type_name: "Forage", + category_id: null, + category_name: "Grass", + sub_category_id: feed ? 1 : null, + sub_category_name: feed ?? "", + feed_uuid: feed ? `uuid-${feed}` : null, + price_per_kg: 5, + quantity_kg: null, + inclusion_limits_enabled: true, + min_kg_per_day: 1, + max_kg_per_day: 3, + }) as FeedItem; + const savedIndia: CattleInfo = { + simulation_name: "Old", + country: "India", + country_id: "1", + currency: "INR", + animal_category: "Lactating Cow", + breed: "Local", + body_weight: 450, + body_weight_gain: 0.5, + body_condition_score: 3, + parity: 2, + days_in_milk: 100, + days_of_pregnancy: 0, + milk_production: 10, + milk_protein_percent: 3, + milk_fat_percent: 4, + average_temperature: 28, + grazing: false, + distance: 0, + topography: "Flat", + milk_price: null, + simulation_language: null, + }; + const twoCards = [card("a", "Napier"), card("b", "Maize")]; + + async function continueWith(country: string) { + getCountries.mockResolvedValueOnce({ data: countries }); + render(); + await formReady(); + await fillBaselineRequired(country); + const btn = screen.getByRole("button", { name: "Continue to Feed" }); + await waitFor(() => expect(btn).not.toBeDisabled()); + fireEvent.click(btn); + } + + it("asks first; Continue clears every card and saves the new country", async () => { + useStore.setState({ user: seedUser(), cattleInfo: savedIndia, feedSelections: twoCards }); + await continueWith("Vietnam"); + + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByText("Changing the country clears the 2 selected feeds. Continue?") + ).toBeInTheDocument(); + expect(push).not.toHaveBeenCalled(); + expect(useStore.getState().cattleInfo?.country_id).toBe("1"); + + fireEvent.click(within(dialog).getByRole("button", { name: "Continue" })); + expect(useStore.getState().feedSelections).toEqual([]); + expect(useStore.getState().cattleInfo).toMatchObject({ country_id: "2", currency: "VND" }); + expect(push).toHaveBeenCalledWith("/feed-selection"); + }); + + it("Cancel changes nothing and saves nothing", async () => { + useStore.setState({ user: seedUser(), cattleInfo: savedIndia, feedSelections: twoCards }); + await continueWith("Vietnam"); + + fireEvent.click( + within(await screen.findByRole("dialog")).getByRole("button", { name: "Cancel" }) + ); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(useStore.getState().feedSelections).toEqual(twoCards); + expect(useStore.getState().cattleInfo).toBe(savedIndia); + expect(push).not.toHaveBeenCalled(); + }); + + it("does not ask when the country ends up the same (India → Vietnam → India)", async () => { + useStore.setState({ user: seedUser(), cattleInfo: savedIndia, feedSelections: twoCards }); + getCountries.mockResolvedValueOnce({ data: countries }); + render(); + await formReady(); + await selectCountry("Vietnam"); + await fillBaselineRequired("India"); + fireEvent.click(screen.getByRole("button", { name: "Continue to Feed" })); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(useStore.getState().feedSelections).toEqual(twoCards); + expect(push).toHaveBeenCalledWith("/feed-selection"); + }); + + it("does not ask when no card has a feed", async () => { + const empty = [card("a", null)]; + useStore.setState({ user: seedUser(), cattleInfo: savedIndia, feedSelections: empty }); + await continueWith("Vietnam"); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(useStore.getState().feedSelections).toEqual(empty); + expect(useStore.getState().cattleInfo?.country_id).toBe("2"); + }); + + it("a restored simulation keeps its feeds and Continue does not ask", async () => { + useStore.setState({ + user: seedUser(), + cattleInfo: { ...savedIndia, country: "Vietnam", country_id: "2" }, + feedSelections: twoCards, + }); + getCountries.mockResolvedValueOnce({ data: countries }); + getUserReports.mockResolvedValueOnce({ + data: { simulations: [{ report_id: "R-1", simulation_id: "SIM-1", country_name: "India" }] }, + }); + getSimulationDetails.mockResolvedValueOnce({ + data: { + simulation_id: "SIM-1", + country_name: "India", + cattle_info: { tp_milk: 3.0, fat_milk: 4.0, grazing: false }, + feed_selection: [ + { feed_type: "Forage", feed_category: "Grass", feed_name: "Napier", feed_id: "uuid-1" }, + ], + }, + }); + render(); + await formReady(); + fireEvent.click(screen.getByRole("button", { name: "Simulation history" })); + fireEvent.click(await screen.findByText("SIM-1")); + await waitFor(() => expect(useStore.getState().cattleInfo?.country_id).toBe("1")); + expect(screen.queryByRole("dialog")).toBeNull(); + + fillSimulationName(); + const btn = screen.getByRole("button", { name: "Continue to Feed" }); + await waitFor(() => expect(btn).not.toBeDisabled()); + fireEvent.click(btn); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(useStore.getState().feedSelections.map((f) => f.feed_uuid)).toEqual(["uuid-1"]); + expect(push).toHaveBeenCalledWith("/feed-selection"); + }); +}); diff --git a/frontend/src/app/__tests__/feed-selection.test.tsx b/frontend/src/app/__tests__/feed-selection.test.tsx index a8eaa59e..09b4a710 100644 --- a/frontend/src/app/__tests__/feed-selection.test.tsx +++ b/frontend/src/app/__tests__/feed-selection.test.tsx @@ -1032,3 +1032,67 @@ describe("feed-selection — simulation country (N5)", () => { expect(useStore.getState().user?.country_id).toBe("1"); }); }); + +describe("feed-selection — one feed per card (search)", () => { + it("a search result already on another card is refused with a message", async () => { + const napier = { + feed_uuid: "napier", + feed_name: "Napier", + feed_type: "Forage", + feed_category: "Grass", + display_name: "Napier", + display_type: "Forage", + display_category: "Grass", + }; + const filled = { + id: "r1", + feed_type_id: null, + feed_type_name: "Forage", + category_id: null, + category_name: "Grass", + sub_category_id: 1, + sub_category_name: "Napier", + feed_uuid: "napier", + price_per_kg: null, + quantity_kg: null, + inclusion_limits_enabled: false, + min_kg_per_day: null, + max_kg_per_day: null, + }; + useStore.setState({ + feedSelections: [ + filled, + { ...filled, id: "r2", sub_category_name: "", feed_uuid: null, sub_category_id: null }, + ], + }); + searchFeeds.mockReset().mockResolvedValue({ data: [napier] }); + // The filled card's own lists, so it settles like a real one. + getFeedCategories.mockReset().mockResolvedValue({ data: ["Grass"] }); + getFeedSubCategories.mockReset().mockResolvedValue({ + data: { standard_feeds: [{ feed_id: "napier", fd_name: "Napier" }], custom_feeds: [] }, + }); + await renderReady(); + + fireEvent.change(screen.getByPlaceholderText(/Search feeds/i), { target: { value: "nap" } }); + await waitFor(() => expect(searchFeeds).toHaveBeenCalled()); + // The result row (it also shows the category), not the card's dropdown. + const result = await waitFor(() => { + const row = screen + .getAllByRole("button", { name: /Napier/ }) + .find((b) => b.textContent?.includes("Grass")); + expect(row).toBeTruthy(); + return row as HTMLElement; + }); + fireEvent.click(result); + + await waitFor(() => + expect(useStore.getState().snackbar).toMatchObject({ + message: "Already this feed is selected", + type: "error", + }) + ); + expect(useStore.getState().feedSelections.filter((f) => f.feed_uuid === "napier")).toHaveLength( + 1 + ); + }); +}); diff --git a/frontend/src/components/FeedRow.tsx b/frontend/src/components/FeedRow.tsx index 60b08758..26bd6239 100644 --- a/frontend/src/components/FeedRow.tsx +++ b/frontend/src/components/FeedRow.tsx @@ -60,6 +60,8 @@ interface FeedRowProps { * without each row re-hitting the API. `display` falls back to the * English identity when a mapping is missing. */ taxonomyLabels?: FeedTaxonomyLabels; + /** True when another card already holds this feed (one feed per card). */ + isFeedTaken?: (feedUuid: string, rowId: string) => boolean; } // Feed Type radios: one row, a column per type (at most 3, then wrap). @@ -83,6 +85,7 @@ export default function FeedRow({ onUpdate, onDelete, taxonomyLabels, + isFeedTaken, }: FeedRowProps) { const user = useStore((s) => s.user); const cattleInfo = useStore((s) => s.cattleInfo); @@ -436,6 +439,10 @@ export default function FeedRow({ value={item.feed_uuid ?? ""} onChange={(v) => { const selected = subCategories.find((s) => s.feed_uuid === v); + if (selected && isFeedTaken?.(selected.feed_uuid, item.id)) { + showSnackbar(t("Already this feed is selected"), "error"); + return; + } onUpdate(item.id, { sub_category_id: selected ? 1 : null, sub_category_name: selected?.feed_name ?? "", diff --git a/frontend/src/components/__tests__/FeedRow.test.tsx b/frontend/src/components/__tests__/FeedRow.test.tsx index 1f2f8bc6..ce03ec21 100644 --- a/frontend/src/components/__tests__/FeedRow.test.tsx +++ b/frontend/src/components/__tests__/FeedRow.test.tsx @@ -1169,3 +1169,59 @@ describe("simulation country (N5)", () => { ); }); }); + +describe("one feed per card", () => { + async function renderPicker(isFeedTaken: (feedUuid: string, rowId: string) => boolean) { + getFeedTypes.mockResolvedValueOnce({ data: [{ type_name: "Forage", display_type: "Forage" }] }); + getFeedCategories.mockResolvedValueOnce({ + data: [{ category_name: "Grass", display_category: "Grass" }], + }); + getFeedSubCategories.mockResolvedValueOnce({ + data: { + standard_feeds: [ + { feed_id: "napier", fd_name: "Napier" }, + { feed_id: "maize", fd_name: "Maize" }, + ], + custom_feeds: [], + }, + }); + const onUpdate = vi.fn(); + render( + + ); + await waitFor(() => expect(getFeedSubCategories).toHaveBeenCalled()); + fireEvent.click(await screen.findByRole("button", { name: "Select feed" })); + fireEvent.click(await screen.findByRole("option", { name: "Napier" })); + return onUpdate; + } + + it("refuses a feed another card already holds and says so", async () => { + const isFeedTaken = vi.fn((uuid: string) => uuid === "napier"); + const onUpdate = await renderPicker(isFeedTaken); + expect(isFeedTaken).toHaveBeenCalledWith("napier", "row-1"); + expect(onUpdate).not.toHaveBeenCalledWith( + "row-1", + expect.objectContaining({ feed_uuid: "napier" }) + ); + expect(useStore.getState().snackbar).toMatchObject({ + message: "Already this feed is selected", + type: "error", + }); + }); + + it("takes a feed no other card holds", async () => { + const onUpdate = await renderPicker(() => false); + expect(onUpdate).toHaveBeenCalledWith( + "row-1", + expect.objectContaining({ feed_uuid: "napier" }) + ); + expect(useStore.getState().snackbar).toBeNull(); + }); +}); diff --git a/frontend/src/components/admin/feeds/DeleteFeedDialog.tsx b/frontend/src/components/admin/feeds/DeleteFeedDialog.tsx index a64e1009..ee0d9aed 100644 --- a/frontend/src/components/admin/feeds/DeleteFeedDialog.tsx +++ b/frontend/src/components/admin/feeds/DeleteFeedDialog.tsx @@ -3,6 +3,7 @@ // Delete-feed confirmation (Admin > Feed Management); split out of admin/feeds/page.tsx (audit B-17). import { COLUMN_LEFT, COLUMN_WIDTH } from "@/lib/column"; import { COLORS } from "@/lib/colors"; +import { fillText } from "@/lib/i18n-ui"; import type { AdminFeed } from "@/components/admin/feeds/shared"; import type React from "react"; @@ -60,9 +61,18 @@ export default function DeleteFeedDialog({ {t("Are you sure you want to delete feed?")}

- {t('"${fd_name}" will be permanently deleted. This action cannot be undone.').replace( - "${fd_name}", - confirmDeleteFeed.fd_name ?? "" + {/* N7: name the country too — the same feed name can exist in + another country, and deleting removes it for that country. */} + {fillText( + confirmDeleteFeed.fd_country_name + ? t( + '"${fd_name}" (${country}) will be permanently deleted. This action cannot be undone.' + ) + : t('"${fd_name}" will be permanently deleted. This action cannot be undone.'), + { + fd_name: confirmDeleteFeed.fd_name ?? "", + country: confirmDeleteFeed.fd_country_name ?? "", + } )}

diff --git a/frontend/src/components/admin/feeds/FeedsTab.tsx b/frontend/src/components/admin/feeds/FeedsTab.tsx index 4e3d422a..40ebc2be 100644 --- a/frontend/src/components/admin/feeds/FeedsTab.tsx +++ b/frontend/src/components/admin/feeds/FeedsTab.tsx @@ -6,9 +6,13 @@ import type { Dispatch, SetStateAction } from "react"; import { COLORS } from "@/lib/colors"; +import CustomSelect from "@/components/CustomSelect"; import { SkeletonCard, type AdminFeed } from "./shared"; export interface FeedsTabProps { + countryFilter: string; + countryNames: string[]; + setCountryFilter: (country: string) => void; deletingFeedId: string | null; filteredFeeds: AdminFeed[]; isLoading: boolean; @@ -21,6 +25,9 @@ export interface FeedsTabProps { } export default function FeedsTab({ + countryFilter, + countryNames, + setCountryFilter, deletingFeedId, filteredFeeds, isLoading, @@ -33,8 +40,29 @@ export default function FeedsTab({ }: FeedsTabProps) { return ( <> - {/* Search by feed name — matches Android til_search_feed */} + {/* Country filter (N7) — same chrome as Admin › Users' country filter. + The same feed name can exist in two countries, so the list can be + narrowed to one before editing or deleting. */}
+
+ ({ value: name, label: name })), + ]} + placeholder={t("All Countries")} + /> +
+
+ + {/* Search by feed name — matches Android til_search_feed */} +

- {searchQuery ? t("No feeds match your search") : t("No feeds found")} + {searchQuery || countryFilter ? t("No feeds match your search") : t("No feeds found")}

) : ( @@ -107,6 +135,14 @@ export default function FeedsTab({ > {f.fd_type || t("N/A")}

+ {/* N7: which country's feed this is — two countries can + have a feed with the same name. */} +

+ {f.fd_country_name || t("N/A")} +

{/* Edit: go_green_15 bg + dark_aquamarine_green tint, diff --git a/frontend/src/components/cattle-info/CountryChangeDialog.tsx b/frontend/src/components/cattle-info/CountryChangeDialog.tsx new file mode 100644 index 00000000..aa042e41 --- /dev/null +++ b/frontend/src/components/cattle-info/CountryChangeDialog.tsx @@ -0,0 +1,80 @@ +"use client"; + +// N8: Cattle Info → Continue with a different country while feed cards are +// filled. Those feeds belong to the old country, so they are cleared — but +// only after the user agrees. Cancel keeps everything and saves nothing. +import { COLUMN_LEFT, COLUMN_WIDTH } from "@/lib/column"; +import { COLORS } from "@/lib/colors"; +import { fillText } from "@/lib/i18n-ui"; +import { useEscapeKey } from "@/lib/use-escape"; + +export interface CountryChangeDialogProps { + count: number; + onCancel: () => void; + onConfirm: () => void; + t: (source: string) => string; +} + +export default function CountryChangeDialog({ + count, + onCancel, + onConfirm, + t, +}: CountryChangeDialogProps) { + useEscapeKey(true, onCancel); + return ( +
+
+

+ {t("Change country?")} +

+

+ {fillText(t("Changing the country clears the ${n} selected feeds. Continue?"), { + n: String(count), + })} +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/cattle-info/useCountryChange.tsx b/frontend/src/components/cattle-info/useCountryChange.tsx new file mode 100644 index 00000000..fe110fa3 --- /dev/null +++ b/frontend/src/components/cattle-info/useCountryChange.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +import type { CattleInfo } from "@/lib/api"; +import { useStore } from "@/lib/store"; +import { filledFeedCount } from "@/lib/feed-selection-rules"; +import CountryChangeDialog from "./CountryChangeDialog"; + +/** + * N8: Cattle Info → Continue. The filled feed cards belong to the SAVED + * country, so switching to another one asks first and then clears them. + * Compared with the saved cattleInfo, not the form's last pick, so + * Kenya → India → Kenya asks nothing; a restore saves its own country, so + * it never trips this either. `proceed` is what Continue does today. + */ +export function useCountryChange(proceed: (info: CattleInfo) => void, t: (s: string) => string) { + const [pending, setPending] = useState<{ info: CattleInfo; count: number } | null>(null); + + const continueWith = (info: CattleInfo) => { + const { cattleInfo, feedSelections } = useStore.getState(); + const saved = cattleInfo?.country_id ? String(cattleInfo.country_id) : ""; + const filled = filledFeedCount(feedSelections); + if (saved && saved !== String(info.country_id) && filled > 0) { + setPending({ info, count: filled }); + return; + } + proceed(info); + }; + + const dialog = pending && ( + setPending(null)} + onConfirm={() => { + setPending(null); + // Every card goes: feed, price, quantity, inclusion limits. + useStore.getState().setFeedSelections([]); + proceed(pending.info); + }} + t={t} + /> + ); + + return { continueWith, dialog }; +} diff --git a/frontend/src/components/feed-row/useFeedCascade.ts b/frontend/src/components/feed-row/useFeedCascade.ts index d293b39a..66ec6d6f 100644 --- a/frontend/src/components/feed-row/useFeedCascade.ts +++ b/frontend/src/components/feed-row/useFeedCascade.ts @@ -458,11 +458,11 @@ export function useFeedCascade({ : null; }) .filter((s): s is FeedSubCategoryItem => s !== null); - const match = list.find( - (s) => - (item.feed_uuid && s.feed_uuid === item.feed_uuid) || - s.feed_name === item.sub_category_name - ); + // By id when the card has one; by name only for a card that has + // none (names are unique per country, not across the library). + const match = item.feed_uuid + ? list.find((s) => s.feed_uuid === item.feed_uuid) + : list.find((s) => s.feed_name === item.sub_category_name); // If the row has a picked feed_uuid but it's not in the new // list (e.g. missing Hindi translation on backend), inject a // synthetic entry using the stored data so the dropdown can diff --git a/frontend/src/lib/__tests__/feed-selection-rules.test.ts b/frontend/src/lib/__tests__/feed-selection-rules.test.ts new file mode 100644 index 00000000..629bf281 --- /dev/null +++ b/frontend/src/lib/__tests__/feed-selection-rules.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { filledFeedCount, isFeedOnOtherCard } from "@/lib/feed-selection-rules"; + +const cards = [ + { id: "a", feed_uuid: "napier", sub_category_name: "Napier" }, + { id: "b", feed_uuid: null, sub_category_name: "" }, + { id: "c", feed_uuid: null, sub_category_name: "Restored by name" }, +]; + +describe("feed-selection rules", () => { + it("counts the cards that have a feed (N8)", () => { + expect(filledFeedCount(cards)).toBe(2); + expect(filledFeedCount([])).toBe(0); + expect(filledFeedCount(null)).toBe(0); + }); + + it("finds a feed held by ANOTHER card only", () => { + expect(isFeedOnOtherCard(cards, "napier", "b")).toBe(true); + expect(isFeedOnOtherCard(cards, "napier", "a")).toBe(false); + expect(isFeedOnOtherCard(cards, "maize", "b")).toBe(false); + expect(isFeedOnOtherCard(cards, null, "b")).toBe(false); + }); +}); diff --git a/frontend/src/lib/data/ui-labels.xlsx b/frontend/src/lib/data/ui-labels.xlsx index 01ff9ca5..87f3fdec 100644 Binary files a/frontend/src/lib/data/ui-labels.xlsx and b/frontend/src/lib/data/ui-labels.xlsx differ diff --git a/frontend/src/lib/feed-selection-rules.ts b/frontend/src/lib/feed-selection-rules.ts new file mode 100644 index 00000000..6b21176d --- /dev/null +++ b/frontend/src/lib/feed-selection-rules.ts @@ -0,0 +1,20 @@ +import type { FeedItem } from "@/lib/api-client/models"; + +type Card = Pick; + +/** Feed cards that have a feed picked (N8: what a country change would clear). */ +export function filledFeedCount(items: readonly Card[] | null | undefined): number { + return (items ?? []).filter((r) => !!r.feed_uuid || r.sub_category_name.trim() !== "").length; +} + +/** + * True when another card already holds this feed. One feed may be on only + * one card: the optimizer would treat two cards of it as two feeds. + */ +export function isFeedOnOtherCard( + items: readonly Card[], + feedUuid: string | null | undefined, + rowId: string | null | undefined +): boolean { + return !!feedUuid && items.some((r) => r.id !== rowId && r.feed_uuid === feedUuid); +}