health score in drawer

This commit is contained in:
2026-09-04 17:20:24 +05:30
parent b8150eb158
commit 9f1593ff87
6 changed files with 151 additions and 11 deletions

View File

@@ -168,6 +168,14 @@ export function HealthScorePanel({ product }: { product: Product }) {
<AlertTriangle size={14} />
<span>Contains {shown.allergens.join(', ')}</span>
</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}
{facts(query.data ?? null).length > 0 ? (

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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); }