This commit is contained in:
sriram
2026-08-11 19:14:03 +05:30
commit 3765e666bc
35 changed files with 7024 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
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>
);
}