Files
catalogue_frontend/src/pages/NutritionAnalyticsPage.jsx
2026-08-12 16:33:39 +05:30

216 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import {
BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis,
Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
import {
Apple, Award, Beef, Droplet, Flame, HeartPulse, Play, RefreshCw, Wheat, UploadCloud,
} from 'lucide-react';
import { api } from '../api/client';
import { Spinner, EmptyState } from '../components/Atoms';
import { NavigationHeader } from '../components/NavigationHeader';
import { UploadExcelModal } from '../components/UploadExcelModal';
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
export function NutritionAnalyticsPage() {
const [dashboard, setDashboard] = useState(null);
const [loading, setLoading] = useState(true);
const [job, setJob] = useState(null);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const pollRef = useRef(null);
const load = () => {
setLoading(true);
api.getNutritionAnalyticsDashboard(8).then(setDashboard).catch(() => setDashboard(null)).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
useEffect(() => {
if (!job || job.status === 'done' || job.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current);
if (job?.status === 'done') load();
return;
}
pollRef.current = setInterval(() => {
api.getNutritionIntelligenceJob(job.job_id).then(setJob).catch(() => {});
}, 2000);
return () => clearInterval(pollRef.current);
}, [job]);
const runEnrich = () => api.enrichNutrition({}).then(setJob);
const runTrain = () => api.trainNutritionModels().then(setJob);
const distribution = dashboard?.distribution;
const enrichmentCounts = distribution?.enrichment_status || {};
const totalTracked = Object.values(enrichmentCounts).reduce((a, b) => a + b, 0);
const hasData = totalTracked > 0;
const distributionData = distribution?.health_score_distribution
? Object.entries(distribution.health_score_distribution).map(([bucket, count]) => ({ bucket, count }))
: [];
return (
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
<NavigationHeader
title="Nutrition Intelligence"
subtitle="Verified nutrition data, health scores & leaderboards across the catalog"
icon={HeartPulse}
/>
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-ink-900/10 bg-white p-4">
<div>
<h3 className="font-display text-sm font-semibold text-ink-950">Nutrition data pipeline</h3>
<p className="text-xs text-slate-400">
{hasData
? `${enrichmentCounts.verified || 0} verified · ${enrichmentCounts.partial || 0} partial · ${enrichmentCounts.unavailable || 0} unavailable of ${totalTracked} tracked products`
: 'No products enriched yet - run enrichment or upload Excel/CSV data.'}
</p>
{job && (
<p className="mt-1 text-xs text-amber-600">
{job.kind === 'enrich' ? 'Enrichment' : 'Training'} {job.status}
{job.total ? ` (${job.processed}/${job.total})` : ''}
{job.detail ? ` - ${job.detail}` : ''}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => setIsUploadOpen(true)}
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
>
<UploadCloud className="h-3.5 w-3.5" />
<span>Upload Nutrition (Excel / CSV)</span>
</button>
<button
onClick={runEnrich}
disabled={job && job.status === 'running'}
className="flex items-center gap-1.5 rounded-full bg-ink-900 px-3 py-1.5 text-xs font-semibold text-paper-100 transition hover:bg-ink-800 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${job?.kind === 'enrich' && job.status === 'running' ? 'animate-spin' : ''}`} />
Auto Enrich OFF
</button>
<button
onClick={runTrain}
disabled={job && job.status === 'running'}
className="flex items-center gap-1.5 rounded-full bg-leaf-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-leaf-700 disabled:opacity-50"
>
<Play className="h-3.5 w-3.5" />
Train Similarity
</button>
</div>
</div>
{loading ? (
<Spinner label="Loading nutrition analytics…" />
) : !hasData ? (
<EmptyState
icon={HeartPulse}
title="No nutrition analytics yet"
subtitle="Run enrichment above (or `python scripts/enrich_nutrition.py`), then training, to populate this dashboard."
/>
) : (
<>
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard title="Healthiest brands" icon={Award}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={dashboard.rankings.healthiest_brands}>
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
<XAxis dataKey="brand" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} />
<Tooltip />
<Bar dataKey="avg_health_score" fill="#3f8556" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Healthiest categories" icon={Apple}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={dashboard.rankings.healthiest_categories}>
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
<XAxis dataKey="category" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} />
<Tooltip />
<Bar dataKey="avg_health_score" fill="#e2a33d" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
{distributionData.length > 0 && (
<div className="mt-4">
<ChartCard title="Health score distribution" icon={HeartPulse}>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={distributionData} dataKey="count" nameKey="bucket" outerRadius={85} label={(e) => e.bucket}>
{distributionData.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</ChartCard>
</div>
)}
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<Leaderboard title="Highest protein" icon={Beef} items={dashboard.leaderboards.highest_protein} field="protein_g" unit="g" />
<Leaderboard title="Highest fiber" icon={Wheat} items={dashboard.leaderboards.highest_fiber} field="dietary_fiber_g" unit="g" />
<Leaderboard title="Lowest sugar" icon={Droplet} items={dashboard.leaderboards.lowest_sugar} field="total_sugar_g" unit="g" />
<Leaderboard title="Lowest sodium" icon={Flame} items={dashboard.leaderboards.lowest_sodium} field="sodium_mg" unit="mg" />
</div>
</>
)}
</div>
<UploadExcelModal
isOpen={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
tabType="nutrition"
tabTitle="Nutritional Intelligence Facts"
onSuccess={() => load()}
/>
</div>
);
}
function ChartCard({ title, icon: Icon, children }) {
return (
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
<h3 className="mb-2 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
<Icon className="h-4 w-4 text-amber-500" /> {title}
</h3>
{children}
</div>
);
}
function Leaderboard({ title, icon: Icon, items = [], field, unit }) {
return (
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
<h3 className="mb-3 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
<Icon className="h-4 w-4 text-leaf-600" /> {title}
</h3>
{items.length === 0 ? (
<p className="py-4 text-center text-sm text-slate-400">No data yet.</p>
) : (
<ol className="space-y-1.5">
{items.map((p, i) => (
<li key={`${p.brand}-${p.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-amber-50/50">
<span className="flex items-center gap-2 truncate">
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{i + 1}</span>
<span className="truncate text-ink-900">{p.product_name || p.image_id}</span>
<span className="shrink-0 text-xs text-slate-400">{p.brand}</span>
</span>
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-600">
{p[field] != null ? `${Number(p[field]).toFixed(1)} ${unit}` : '–'}
</span>
</li>
))}
</ol>
)}
</div>
);
}