health score
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
* npx tsx scripts/checkHealthPanel.ts
|
* 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';
|
import { facts, present, BAND_LABEL } from '../src/features/store-admin/healthScore';
|
||||||
|
|
||||||
const CASES: { label: string; brand: string; imageId: string; expect: string }[] = [
|
const CASES: { label: string; brand: string; imageId: string; expect: string }[] = [
|
||||||
@@ -74,3 +74,18 @@ for (const testCase of CASES) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
line();
|
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();
|
||||||
|
|||||||
@@ -97,6 +97,47 @@ async function read<T>(path: string): Promise<T | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string[]> | null = null;
|
||||||
|
|
||||||
|
function normalise(brand: string): string {
|
||||||
|
return brand.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBrand(raw: string): Promise<string> {
|
||||||
|
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 = {
|
export const nutritionApi = {
|
||||||
/**
|
/**
|
||||||
* One product's score and nutrition.
|
* 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
|
* that must not be collapsed, because the second means "coming soon" and the
|
||||||
* first means "this product was never in the catalogue".
|
* first means "this product was never in the catalogue".
|
||||||
*/
|
*/
|
||||||
forProduct: (brand: string, imageId: string) =>
|
forProduct: async (brand: string, imageId: string) => {
|
||||||
read<NutritionScore>(
|
// Resolved first: our catalogue spells brands in snake_case and theirs does
|
||||||
`/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`,
|
// not, and the mismatch reads as "unscored" rather than as an error.
|
||||||
),
|
const resolved = await resolveBrand(brand);
|
||||||
|
return read<NutritionScore>(
|
||||||
|
`/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. */
|
/** True when the service knows the product but has not scored it yet. */
|
||||||
export function isUnscored(score: NutritionScore | null): boolean {
|
export function isUnscored(score: NutritionScore | null): boolean {
|
||||||
return score !== null && (score.health_score === null || score.health_score === undefined);
|
return score !== null && (score.health_score === null || score.health_score === undefined);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { VStack } from '@astryxdesign/core/VStack';
|
|||||||
import { Check, DownloadCloud, ImageOff, Info } from 'lucide-react';
|
import { Check, DownloadCloud, ImageOff, Info } from 'lucide-react';
|
||||||
import type { CatalogueProduct } from '@/api/types';
|
import type { CatalogueProduct } from '@/api/types';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
|
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One global-catalogue product, in full.
|
* One global-catalogue product, in full.
|
||||||
@@ -205,6 +206,17 @@ export function CatalogueDetailDrawer({
|
|||||||
</VStack>
|
</VStack>
|
||||||
) : null}
|
) : 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. */}
|
||||||
|
<HealthScorePanel product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }} />
|
||||||
|
|
||||||
{product.highlights?.length || product.nutrients?.length ? (
|
{product.highlights?.length || product.nutrients?.length ? (
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
{product.highlights?.length ? (
|
{product.highlights?.length ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user