From 9f1593ff8770f03afcecc2e8fd34ea71d0e05180 Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 4 Sep 2026 17:20:24 +0530 Subject: [PATCH] health score in drawer --- package.json | 1 + scripts/checkHealthPanel.ts | 76 +++++++++++++++++++ src/features/store-admin/HealthScorePanel.tsx | 8 ++ src/features/store-admin/healthScore.test.ts | 27 +++++++ src/features/store-admin/healthScore.ts | 40 +++++++--- src/features/store-admin/pages/deliveries.css | 10 +++ 6 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 scripts/checkHealthPanel.ts diff --git a/package.json b/package.json index 321d94f..11cbb65 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "contract": "node scripts/contract.mjs", "db": "node scripts/db.mjs", "appgap": "node scripts/appgap.mjs", + "check:health": "tsx scripts/checkHealthPanel.ts", "verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"", "appsweep": "node scripts/appsweep.mjs", "appfix": "node scripts/appfix.mjs" diff --git a/scripts/checkHealthPanel.ts b/scripts/checkHealthPanel.ts new file mode 100644 index 0000000..34ca84f --- /dev/null +++ b/scripts/checkHealthPanel.ts @@ -0,0 +1,76 @@ +/** + * What the health panel would render, for real products, against the live + * service. + * + * Not a test — the tests pin behaviour against a frozen fixture. This runs the + * SAME functions the panel calls against whatever the service is returning + * right now, which is the only way to catch the service changing under us. + * + * npx tsx scripts/checkHealthPanel.ts + */ + +import { nutritionApi } 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 }[] = [ + { + label: 'Cadbury 5 Star 200g', + brand: 'Cadbury', + imageId: 'cadbury_cadbury_5_star_200g', + expect: 'a score, with a low-confidence caveat', + }, + { + label: 'Godrej Hit Spray (INSECTICIDE)', + brand: 'Godrej', + imageId: 'godrej_hit_spray_1101d017', + expect: 'NO score — blocked by the edibility guard', + }, + { + label: 'Naga Sooji', + brand: 'Naga', + imageId: 'naga_naga_sooji_100g', + expect: 'a high score, allergen declared', + }, + { + label: 'Aachi Baby Fryums 100g (a real merchant product)', + brand: 'Aachi', + imageId: 'aachi_aachi_baby_fryums_100g', + expect: 'known but unscored', + }, +]; + +const line = (s = '') => console.log(s); + +for (const testCase of CASES) { + line(); + line('─'.repeat(72)); + line(`${testCase.label}`); + line(`expected: ${testCase.expect}`); + line('─'.repeat(72)); + + const raw = await nutritionApi.forProduct(testCase.brand, testCase.imageId); + const shown = present(raw); + + if (shown.isEmpty) { + line(' → "No health score available for this product yet."'); + continue; + } + if (shown.isPending) { + line(' → "This product is in the catalogue but has not been scored yet."'); + line(` (service sent health_score=${raw?.health_score}, category="${raw?.category}")`); + continue; + } + + line(` SCORE ${shown.display}/100 ${shown.band ? BAND_LABEL[shown.band] : ''}`); + for (const good of shown.good) line(` ✓ ${good}`); + for (const caution of shown.cautions) line(` ! ${caution}`); + if (shown.tags.length) line(` TAGS ${shown.tags.join(' · ')}`); + if (shown.allergens.length) line(` ALLERGENS Contains ${shown.allergens.join(', ')}`); + else if (shown.allergensUnconfirmed) line(' ALLERGENS not confirmed — check the pack'); + const rows = facts(raw); + if (rows.length) line(` PER 100g ${rows.map((r) => `${r.label} ${r.value}`).join(' · ')}`); + if (shown.caveat) line(` CAVEAT ${shown.caveat}`); + if (shown.source) line(` SOURCE ${shown.source.label}`); +} + +line(); diff --git a/src/features/store-admin/HealthScorePanel.tsx b/src/features/store-admin/HealthScorePanel.tsx index b44bc29..9d2dd41 100644 --- a/src/features/store-admin/HealthScorePanel.tsx +++ b/src/features/store-admin/HealthScorePanel.tsx @@ -168,6 +168,14 @@ export function HealthScorePanel({ product }: { product: Product }) { Contains {shown.allergens.join(', ')} + ) : shown.allergensUnconfirmed ? ( + // An empty list on a weak match is not "contains none" — it is silence, + // and silence in this row is the one thing here that could hurt + // somebody. Said out loud rather than rendered as an absence. +
+ + Allergens not confirmed for this product — check the pack. +
) : null} {facts(query.data ?? null).length > 0 ? ( diff --git a/src/features/store-admin/healthScore.test.ts b/src/features/store-admin/healthScore.test.ts index 23c4d0a..5a0378d 100644 --- a/src/features/store-admin/healthScore.test.ts +++ b/src/features/store-admin/healthScore.test.ts @@ -227,3 +227,30 @@ test('a scored chocolate still comes through', () => { // The guard must not swallow the case it exists to protect. assert.equal(present(fiveStar).score, 18.1); }); + +/* ── Allergen silence ────────────────────────────────────────────────────── */ + +test('an empty allergen list on a weak match is not "contains none"', () => { + /* + The service accepts a source match down to 0.32 confidence, and + data_status: "verified" speaks only to the numbers being real — not to the + record being this product. Sampled 40 scored products: 37 matched below 0.7, + 26 below 0.5. So an empty list is usually silence, not a clean bill. + */ + const shown = present({ ...fiveStar, allergens: [], match_confidence: 0.45 }); + assert.deepEqual(shown.allergens, []); + assert.equal(shown.allergensUnconfirmed, true); +}); + +test('an empty allergen list on a CONFIDENT match is taken at face value', () => { + const shown = present({ ...fiveStar, allergens: [], match_confidence: 0.95 }); + assert.equal(shown.allergensUnconfirmed, false); +}); + +test('a declared allergen is shown regardless of confidence', () => { + // A false positive sends someone to read the pack. A false negative sends + // them to hospital. The asymmetry decides it. + const shown = present({ ...fiveStar, allergens: ['Gluten'], match_confidence: 0.33 }); + assert.deepEqual(shown.allergens, ['Gluten']); + assert.equal(shown.allergensUnconfirmed, false); +}); diff --git a/src/features/store-admin/healthScore.ts b/src/features/store-admin/healthScore.ts index 489bb3b..d927cc3 100644 --- a/src/features/store-admin/healthScore.ts +++ b/src/features/store-admin/healthScore.ts @@ -20,20 +20,21 @@ export type Band = 'excellent' | 'good' | 'fair' | 'poor'; * panel this fallback is not an edge case, it is the only path. That makes the * thresholds below load-bearing rather than cosmetic. * - * They are the SERVICE'S thresholds, not ours, read off its own output: + * They are the SERVICE'S thresholds, not ours. Derived from its own output — + * excellent 80.0–89.9, good 60.2–78.8, fair 50.0–58.9, poor 11.2–38.3 — and + * since CONFIRMED by the team in writing: * - * excellent 80.0 – 89.9 - * good 60.2 – 78.8 - * fair 50.0 – 58.9 - * poor 11.2 – 38.3 + * excellent >= 80 + * good 60 – 79.9 also their "healthy product" cut in analytics + * fair 40 – 59.9 confirmed 40, not 50 + * poor < 40 * - * So: 80 / 60 / 40. The 60 is confirmed by the team as the same cut - * `store_healthy_distribution` counts as healthy. The fair/poor line is the one - * value the data cannot pin — nothing scores between 38.3 and 50.0 — and 40 is - * what completes that ladder. + * The fair/poor line was the one value the data could not pin: nothing scores + * between 38.3 and 50.0. We inferred 40 and they confirmed 40. * - * Picking our own numbers here would be the same API disagreeing with itself - * depending on which of its endpoints a screen happened to call. + * Delete this fallback once `health_band` is on the per-product response — + * it is on their list. Until then, picking our own numbers here would be the + * same API disagreeing with itself depending which endpoint a screen called. */ export function bandFor(score: number, sent?: string | null): Band { const given = (sent ?? '').trim().toLowerCase(); @@ -139,6 +140,20 @@ export interface Presented { * hurt somebody. */ caveat: string; + /** + * True when an EMPTY allergen list must not be read as "contains none". + * + * The service accepts a source match down to 0.32 confidence, and + * `data_status: "verified"` speaks only to the numbers being real — not to + * the record being this product. Sampling 40 scored products: 37 matched + * below 0.7 and 26 below 0.5. + * + * A declared allergen is still shown, because a false positive sends someone + * to read the pack and a false negative sends them to hospital. What must not + * happen is silence standing in for "none", which is what an empty list + * rendered as nothing looks like. + */ + allergensUnconfirmed: boolean; source: { label: string; url: string } | null; } @@ -161,6 +176,7 @@ export function present(score: NutritionScore | null): Presented { tags: [], allergens: [], caveat: '', + allergensUnconfirmed: false, source: null, }; @@ -196,6 +212,8 @@ export function present(score: NutritionScore | null): Presented { tags: clean(score.diet_tags), allergens: clean(score.allergens), caveat, + // An empty list is only trustworthy when the match is. + allergensUnconfirmed: clean(score.allergens).length === 0 && caveat !== '', source: score.source_url ? { label: score.data_source || 'source', url: score.source_url } : null, diff --git a/src/features/store-admin/pages/deliveries.css b/src/features/store-admin/pages/deliveries.css index ee577b2..cfca904 100644 --- a/src/features/store-admin/pages/deliveries.css +++ b/src/features/store-admin/pages/deliveries.css @@ -240,3 +240,13 @@ text-decoration: none; } .health-source:hover { color: var(--color-brand); text-decoration: underline; } + +/* Allergens we could not confirm read differently from allergens we found: + grey rather than amber, because it is an absence of knowledge, not a warning + about the food. Both sit in the same row so neither can be missed. */ +.health-allergens[data-tone='unknown'] { + border-color: var(--color-line); + background: var(--color-surface-subtle); + color: var(--color-ink-2); +} +.health-allergens[data-tone='unknown'] svg { color: var(--color-ink-3); }