216 lines
9.6 KiB
JavaScript
216 lines
9.6 KiB
JavaScript
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>
|
||
);
|
||
}
|