health score in drawer
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"contract": "node scripts/contract.mjs",
|
"contract": "node scripts/contract.mjs",
|
||||||
"db": "node scripts/db.mjs",
|
"db": "node scripts/db.mjs",
|
||||||
"appgap": "node scripts/appgap.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\"",
|
"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",
|
"appsweep": "node scripts/appsweep.mjs",
|
||||||
"appfix": "node scripts/appfix.mjs"
|
"appfix": "node scripts/appfix.mjs"
|
||||||
|
|||||||
76
scripts/checkHealthPanel.ts
Normal file
76
scripts/checkHealthPanel.ts
Normal file
@@ -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();
|
||||||
@@ -168,6 +168,14 @@ export function HealthScorePanel({ product }: { product: Product }) {
|
|||||||
<AlertTriangle size={14} />
|
<AlertTriangle size={14} />
|
||||||
<span>Contains {shown.allergens.join(', ')}</span>
|
<span>Contains {shown.allergens.join(', ')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : 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.
|
||||||
|
<div className="health-allergens" data-tone="unknown">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>Allergens not confirmed for this product — check the pack.</span>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{facts(query.data ?? null).length > 0 ? (
|
{facts(query.data ?? null).length > 0 ? (
|
||||||
|
|||||||
@@ -227,3 +227,30 @@ test('a scored chocolate still comes through', () => {
|
|||||||
// The guard must not swallow the case it exists to protect.
|
// The guard must not swallow the case it exists to protect.
|
||||||
assert.equal(present(fiveStar).score, 18.1);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
* panel this fallback is not an edge case, it is the only path. That makes the
|
||||||
* thresholds below load-bearing rather than cosmetic.
|
* 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
|
* excellent >= 80
|
||||||
* good 60.2 – 78.8
|
* good 60 – 79.9 also their "healthy product" cut in analytics
|
||||||
* fair 50.0 – 58.9
|
* fair 40 – 59.9 confirmed 40, not 50
|
||||||
* poor 11.2 – 38.3
|
* poor < 40
|
||||||
*
|
*
|
||||||
* So: 80 / 60 / 40. The 60 is confirmed by the team as the same cut
|
* The fair/poor line was the one value the data could not pin: nothing scores
|
||||||
* `store_healthy_distribution` counts as healthy. The fair/poor line is the one
|
* between 38.3 and 50.0. We inferred 40 and they confirmed 40.
|
||||||
* value the data cannot pin — nothing scores between 38.3 and 50.0 — and 40 is
|
|
||||||
* what completes that ladder.
|
|
||||||
*
|
*
|
||||||
* Picking our own numbers here would be the same API disagreeing with itself
|
* Delete this fallback once `health_band` is on the per-product response —
|
||||||
* depending on which of its endpoints a screen happened to call.
|
* 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 {
|
export function bandFor(score: number, sent?: string | null): Band {
|
||||||
const given = (sent ?? '').trim().toLowerCase();
|
const given = (sent ?? '').trim().toLowerCase();
|
||||||
@@ -139,6 +140,20 @@ export interface Presented {
|
|||||||
* hurt somebody.
|
* hurt somebody.
|
||||||
*/
|
*/
|
||||||
caveat: string;
|
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;
|
source: { label: string; url: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +176,7 @@ export function present(score: NutritionScore | null): Presented {
|
|||||||
tags: [],
|
tags: [],
|
||||||
allergens: [],
|
allergens: [],
|
||||||
caveat: '',
|
caveat: '',
|
||||||
|
allergensUnconfirmed: false,
|
||||||
source: null,
|
source: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,6 +212,8 @@ export function present(score: NutritionScore | null): Presented {
|
|||||||
tags: clean(score.diet_tags),
|
tags: clean(score.diet_tags),
|
||||||
allergens: clean(score.allergens),
|
allergens: clean(score.allergens),
|
||||||
caveat,
|
caveat,
|
||||||
|
// An empty list is only trustworthy when the match is.
|
||||||
|
allergensUnconfirmed: clean(score.allergens).length === 0 && caveat !== '',
|
||||||
source: score.source_url
|
source: score.source_url
|
||||||
? { label: score.data_source || 'source', url: score.source_url }
|
? { label: score.data_source || 'source', url: score.source_url }
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -240,3 +240,13 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.health-source:hover { color: var(--color-brand); text-decoration: underline; }
|
.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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user