From bcb5d1de286f9bc80417256f347a85f6641e04f8 Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 5 Oct 2026 17:05:51 +0530 Subject: [PATCH] health score toggle ui --- src/features/catalogue/BulkImportDrawer.tsx | 210 ++++++++++++++++++ src/features/catalogue/CatalogueBrowser.tsx | 108 +++++++-- src/features/catalogue/CatalogueCard.tsx | 9 +- src/features/store-admin/HealthScorePanel.tsx | 88 ++++---- .../store-admin/healthScoreHooks.test.ts | 66 ++++++ src/index.css | 2 +- 6 files changed, 406 insertions(+), 77 deletions(-) create mode 100644 src/features/catalogue/BulkImportDrawer.tsx create mode 100644 src/features/store-admin/healthScoreHooks.test.ts diff --git a/src/features/catalogue/BulkImportDrawer.tsx b/src/features/catalogue/BulkImportDrawer.tsx new file mode 100644 index 0000000..b20ff30 --- /dev/null +++ b/src/features/catalogue/BulkImportDrawer.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Switch } from '@astryxdesign/core/Switch'; +import { nutritionApi } from '@/api/nutrition'; +import type { CatalogueProduct } from '@/api/types'; +import { Drawer } from '@/features/store-admin/Drawer'; +import { DrawerButton, Note } from '@/features/store-admin/drawerKit'; +import { BAND_LABEL, isEdible, present } from '@/features/store-admin/healthScore'; +import { catalogueKey } from '@/api/catalogue'; + +/** + * Adding several products at once, and deciding the health score for each. + * + * ── Why a drawer here and not on the single-product path ──────────────────── + * + * A single product already has a drawer — the catalogue detail one — which shows + * its score and carries the toggle, so a second panel there was repeating what + * was already on screen. A batch has no such drawer, so this is the first place + * the question can be asked at all. + * + * ── Why the toggles are per product ───────────────────────────────────────── + * + * Because a batch is not one decision. Three products can easily be two that + * should show a rating and one that should not — a 47%-confidence match on a + * product the shopkeeper knows is mis-matched — and a single switch for the + * whole selection forces that into a lie either way. + * + * ── Why each row shows its score ──────────────────────────────────────────── + * + * The same reason the detail drawer does: "show health score?" against a product + * name is unanswerable. Beside "22/100 · matched at 47%" it answers itself. The + * rows that have no score say so and carry no toggle, so nobody sets a control + * that decides nothing. + */ +export function BulkImportDrawer({ + products, + actionLabel, + isBusy, + defaultShowScore, + onCancel, + onConfirm, +}: { + products: CatalogueProduct[]; + actionLabel: string; + isBusy: boolean; + /** The screen's setting, which every row starts at. */ + defaultShowScore: boolean; + onCancel: () => void; + /** + * Keyed by `catalogueKey`. A product missing from the map has no score, and + * the import says nothing about it rather than sending a choice nobody made. + */ + onConfirm: (choices: Map) => void; +}) { + /* + Only the rows the shopkeeper has actually moved. + + Starting empty rather than pre-filling every product with the default means + "untouched" and "deliberately set to the default" stay the same thing, which + they are — and a row whose score never loads cannot end up contributing a + choice about a score nobody saw. + */ + const [choices, setChoices] = useState>(new Map()); + + const choiceFor = (key: string) => choices.get(key) ?? defaultShowScore; + const setChoice = (key: string, show: boolean) => + setChoices((previous) => new Map(previous).set(key, show)); + + function setAll(show: boolean) { + setChoices(new Map(products.map((product) => [catalogueKey(product), show]))); + } + + return ( + + + onConfirm(choices)} + /> + + } + > + + The nutrition figures are shown either way — this is only the rating. Any of these can be + changed later from the product. + + + {/* For the ordinary case, where the whole batch goes the same way. Without + it a shopkeeper who wants forty products' ratings off has forty clicks + to make, which is how a feature becomes one nobody uses. */} +
+ setAll(true)} /> + setAll(false)} /> +
+ +
+ {products.map((product) => ( + setChoice(catalogueKey(product), show)} + /> + ))} +
+
+ ); +} + +/** + * One product in the batch, with its score and its toggle. + * + * Each row asks for its own score. They are the same lookups the product drawer + * would make, cached for an hour by the query client and shared with it — so + * opening a product after a batch costs nothing, and re-opening this drawer + * costs nothing either. + */ +function BulkImportRow({ + product, + isShown, + onChange, +}: { + product: CatalogueProduct; + isShown: boolean; + onChange: (show: boolean) => void; +}) { + const brand = (product.brand ?? '').trim(); + const imageId = (product.image_id ?? '').trim(); + + // Non-food never has a score — the service has rated insecticide 80/100, so + // an allowlist gates it — and asking about one is a request for nothing. + const isFood = isEdible(product.category); + const query = useQuery({ + queryKey: ['nutrition', brand, imageId], + queryFn: () => nutritionApi.forProduct(brand, imageId), + enabled: Boolean(brand && imageId && isFood), + staleTime: 60 * 60_000, + refetchOnWindowFocus: false, + retry: false, + }); + + const shown = present(query.data ?? null); + const hasScore = isFood && !shown.isEmpty && !shown.isPending && shown.score !== null; + const isChecking = isFood && Boolean(brand && imageId) && query.isLoading; + + return ( +
+
+
+ {product.product_name} +
+
+ {isChecking + ? 'Checking…' + : hasScore + ? `${shown.display}/100 · ${shown.band ? BAND_LABEL[shown.band] : ''}${ + shown.caveat ? ` · ${shown.caveat.match(/\d+%/)?.[0] ?? ''} match` : '' + }` + : isFood + ? 'No health score yet' + : 'Not food — no health score'} +
+
+ + {hasScore ? ( + /* The label is hidden visually and kept for a screen reader: the product + name sitting beside it is what a sighted reader uses, and repeating + "Show health score for Britannia Good Day…" on every row would be the + same sentence forty times down a list. */ + + ) : ( + /* No toggle, and the space kept, so the rows do not jag left and right + down the list as scores resolve at different speeds. */ + + )} +
+ ); +} diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx index 9bc782f..00012d3 100644 --- a/src/features/catalogue/CatalogueBrowser.tsx +++ b/src/features/catalogue/CatalogueBrowser.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@astryxdesign/core/Button'; +import { Switch } from '@astryxdesign/core/Switch'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { HStack } from '@astryxdesign/core/HStack'; import { IconButton } from '@astryxdesign/core/IconButton'; @@ -27,6 +28,7 @@ import { } from '@/queries/hooks'; import { CatalogueCard } from './CatalogueCard'; import { CatalogueSidebar } from './CatalogueSidebar'; +import { BulkImportDrawer } from './BulkImportDrawer'; import { CatalogueDetailDrawer } from './CatalogueDetailDrawer'; const PAGE_SIZE = 24; @@ -93,6 +95,30 @@ export function CatalogueBrowser({ const [debounced, setDebounced] = useState(''); const [busy, setBusy] = useState(null); const [justImported, setJustImported] = useState>(new Set()); + /* + Whether products added from this screen show their health score. + + Two of the three ways to add a product never open the drawer — a card's Add + is one click and a bulk add is one click for forty — so without this they + had no say at all, and "showing" was decided for them. + + One control for the whole screen rather than a question per product: a + prompt on every card add is the thing that was taken out, and a toggle asked + once for forty products is a question nobody answers honestly. The drawer's + own toggle still wins for the product it is open on, because somebody + reading that score has better information than this default does. + */ + const [showScoreOnAdd, setShowScoreOnAdd] = useState(true); + /* + The batch waiting on its per-product choices. + + A selection is not one decision: three products can be two whose rating + should show and one whose should not, and a single switch for the whole set + forces that into a lie either way. Unlike the single-product path, a batch + has no drawer of its own — so this is the first place the question can be + asked at all rather than a second panel repeating one. + */ + const [batch, setBatch] = useState(null); const [open, setOpen] = useState(null); const [category, setCategory] = useState(''); const [page, setPage] = useState(1); @@ -198,15 +224,22 @@ export function CatalogueBrowser({ */ mutationFn: async ({ products, - showHealthScore, + choices, }: { products: CatalogueProduct[]; - showHealthScore: boolean | undefined; + /* Keyed by `catalogueKey`. A product missing from the map takes the + screen's setting — the import request carries one value per row, so a + mixed batch is one call, not one per choice. */ + choices: Map; }) => { const ids = await aisleIds(); return productsApi.importFromCatalogue( products.map((product) => - importRowFor(product, aisleIdForCategory(categoryNameFor(product), ids), showHealthScore), + importRowFor( + product, + aisleIdForCategory(categoryNameFor(product), ids), + choices.get(catalogueKey(product)) ?? showScoreOnAdd, + ), ), ); }, @@ -331,19 +364,22 @@ export function CatalogueBrowser({ }; } + /* + Add straight from the card, in one click, as it has always been. + + No question asked here, deliberately. The health score toggle lives in the + product's own drawer, where the score and its confidence are on screen — and + a card has neither, so a toggle on this path would be asking somebody to + judge a rating they cannot see. + + The product imports with its score showing, which is the default and what + every product did before the toggle existed. Anyone who wants it off opens + the product and turns it off, which is the same control in the same place as + changing their mind later. + */ async function importDirect(product: CatalogueProduct) { if (!tenantid || !locationid) return; - - /* - Opens the product rather than importing on the spot. - - The drawer already shows this product's health score and how confident - the match is, and the import now carries a decision about exactly that — - so the question is asked where the answer is visible. A separate - confirmation would be a second panel repeating what this one already - renders. - */ - setOpen(product); + await confirmImport(product, showScoreOnAdd); } /* @@ -542,6 +578,20 @@ export function CatalogueBrowser({ + {/* The decision for everything added from this screen. + + Beside the add controls rather than in a settings panel, + because it is read at the moment it applies — somebody + about to add forty products can see what those forty will + do. Ticked by default, which is what every product did + before the toggle existed. */} + + {selection.count > 0 ? (