From 37b0833328178f50d64ba4c2a51c6264106ffbc6 Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 4 Sep 2026 17:31:57 +0530 Subject: [PATCH] health score --- scripts/checkHealthPanel.ts | 17 +++++- src/api/nutrition.ts | 56 +++++++++++++++++-- .../catalogue/CatalogueDetailDrawer.tsx | 12 ++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/scripts/checkHealthPanel.ts b/scripts/checkHealthPanel.ts index 34ca84f..1aaacda 100644 --- a/scripts/checkHealthPanel.ts +++ b/scripts/checkHealthPanel.ts @@ -9,7 +9,7 @@ * npx tsx scripts/checkHealthPanel.ts */ -import { nutritionApi } from '../src/api/nutrition'; +import { nutritionApi, __resolveBrand } from '../src/api/nutrition'; import { facts, present, BAND_LABEL } from '../src/features/store-admin/healthScore'; const CASES: { label: string; brand: string; imageId: string; expect: string }[] = [ @@ -74,3 +74,18 @@ for (const testCase of CASES) { } line(); + +/* ── Brand vocabularies ──────────────────────────────────────────────────── */ +/* Our catalogue writes snake_case, theirs writes Title Case with a separator + that is sometimes a space and sometimes a hyphen. A mismatch returns + health_score: null — indistinguishable from an unscored product — so this + checks the resolution rather than trusting it. */ + +line('─'.repeat(72)); +line('Brand resolution: our spelling → theirs'); +line('─'.repeat(72)); +for (const ours of ['cadbury', 'coca_cola', 'brooke_bond', '24_mantra', 'colgate_palmolive', 'aachi']) { + const theirs = await __resolveBrand(ours); + line(` ${ours.padEnd(20)} → ${theirs}`); +} +line(); diff --git a/src/api/nutrition.ts b/src/api/nutrition.ts index a5a142f..08edc32 100644 --- a/src/api/nutrition.ts +++ b/src/api/nutrition.ts @@ -97,6 +97,47 @@ async function read(path: string): Promise { } } +/** + * Their brand spelling, resolved from ours. + * + * The two catalogues agree on every brand and disagree on how to write it: + * + * ours theirs + * cadbury → Cadbury + * coca_cola → Coca-Cola underscore becomes a HYPHEN + * brooke_bond → Brooke Bond underscore becomes a SPACE + * 24_mantra → 24 Mantra + * + * Which separator an underscore becomes cannot be derived — it is a hyphen for + * Coca-Cola and Colgate-Palmolive and a space for everything else. So the list + * is fetched and matched on a normalised form rather than guessed at. + * + * This is not cosmetic. `GET /nutrition/cadbury/...` returns + * `health_score: null` — a well-formed answer meaning "no score", not an error + * — so getting the case wrong looks exactly like a product nobody has scored, + * on every product, forever. + * + * Cached for the process: a brand list changes when the scraper learns a new + * brand, which is not during a session. + */ +let brandsPromise: Promise | null = null; + +function normalise(brand: string): string { + return brand.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +async function resolveBrand(raw: string): Promise { + const wanted = normalise(raw); + if (!wanted) return raw; + + brandsPromise ??= read<{ brands?: string[] }>('/brands').then((r) => r?.brands ?? []); + const brands = await brandsPromise; + + // Their exact spelling if we know it; ours unchanged if we do not, so a brand + // they have not listed still gets a real attempt rather than being dropped. + return brands.find((candidate) => normalise(candidate) === wanted) ?? raw; +} + export const nutritionApi = { /** * One product's score and nutrition. @@ -106,12 +147,19 @@ export const nutritionApi = { * that must not be collapsed, because the second means "coming soon" and the * first means "this product was never in the catalogue". */ - forProduct: (brand: string, imageId: string) => - read( - `/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`, - ), + forProduct: async (brand: string, imageId: string) => { + // Resolved first: our catalogue spells brands in snake_case and theirs does + // not, and the mismatch reads as "unscored" rather than as an error. + const resolved = await resolveBrand(brand); + return read( + `/nutrition/${encodeURIComponent(resolved)}/${encodeURIComponent(imageId)}`, + ); + }, }; +/** Exposed for the check script, which asserts the two vocabularies still line up. */ +export const __resolveBrand = resolveBrand; + /** True when the service knows the product but has not scored it yet. */ export function isUnscored(score: NutritionScore | null): boolean { return score !== null && (score.health_score === null || score.health_score === undefined); diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx index f5298ac..7a8524b 100644 --- a/src/features/catalogue/CatalogueDetailDrawer.tsx +++ b/src/features/catalogue/CatalogueDetailDrawer.tsx @@ -12,6 +12,7 @@ import { VStack } from '@astryxdesign/core/VStack'; import { Check, DownloadCloud, ImageOff, Info } from 'lucide-react'; import type { CatalogueProduct } from '@/api/types'; import { Drawer } from '@/features/store-admin/Drawer'; +import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel'; /** * One global-catalogue product, in full. @@ -205,6 +206,17 @@ export function CatalogueDetailDrawer({ ) : null} + {/* ── Health score ────────────────────────────────────────────────── + On the CATALOGUE drawer as well as the tenant one, and this is the + drawer where it actually has data: the scored products all live in the + global catalogue. A merchant's own shelf overlaps it barely at all + today — 0 of 26 — so a panel only on the tenant product page shows + nothing to anybody, which is exactly what happened. + + `brand` and `image_id` come straight off the catalogue row, so no + lookup is needed to find the key. */} + + {product.highlights?.length || product.nutrients?.length ? (
{product.highlights?.length ? (