Files
ember-market-frontend/components/analytics/PredictionsChart.tsx
g f7af5b933d
All checks were successful
Build Frontend / build (push) Successful in 1m5s
Optimize PredictionsChart with batch data loading
Refactors PredictionsChart to fetch all prediction data in a single batch for instant client-side switching between horizons and simulation factors. Updates state management and effects to utilize the pre-cached batch data, reducing API calls and improving responsiveness. Minor UI text update to remove TensorFlow.js mention.
2026-01-12 04:33:31 +00:00

841 lines
34 KiB
TypeScript

"use client";
import { useState, useEffect, memo, useMemo, useCallback } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
TrendingUp,
TrendingDown,
Package,
DollarSign,
AlertTriangle,
RefreshCw,
Calendar,
BarChart3,
Brain,
Layers,
Zap,
Info,
Download,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Skeleton } from "@/components/ui/skeleton";
import CountUp from "react-countup";
import {
getPredictionsOverviewWithStore,
getStockPredictionsWithStore,
type PredictionsOverview,
type StockPredictionsResponse,
} from "@/lib/services/analytics-service";
import { formatGBP } from "@/utils/format";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { format } from "date-fns";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
} from "recharts";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Slider } from "@/components/ui/slider";
interface PredictionsChartProps {
timeRange?: number;
}
export default function PredictionsChart({
timeRange = 90,
}: PredictionsChartProps) {
const [predictions, setPredictions] = useState<PredictionsOverview | null>(
null,
);
const [baselinePredictions, setBaselinePredictions] = useState<PredictionsOverview | null>(
null,
);
// Batch data holds all pre-cached predictions for instant switching
const [batchData, setBatchData] = useState<{
[horizon: string]: {
[simulationFactor: string]: PredictionsOverview;
};
} | null>(null);
const [stockPredictions, setStockPredictions] =
useState<StockPredictionsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [isSimulating, setIsSimulating] = useState(false);
const [daysAhead, setDaysAhead] = useState(7);
const [activeTab, setActiveTab] = useState<"overview" | "stock">("overview");
const [simulationFactor, setSimulationFactor] = useState(0);
const [committedSimulationFactor, setCommittedSimulationFactor] = useState(0);
const { toast } = useToast();
// Fetch all predictions in batch (for instant client-side switching)
const fetchBatchData = async () => {
try {
setLoading(true);
const { getBatchPredictionsWithStore } = await import("@/lib/services/analytics-service");
const [batchResponse, stock] = await Promise.all([
getBatchPredictionsWithStore(timeRange),
getStockPredictionsWithStore(timeRange),
]);
if (batchResponse.success && batchResponse.predictions) {
setBatchData(batchResponse.predictions);
// Set initial predictions from batch
const horizonData = batchResponse.predictions[daysAhead.toString()];
if (horizonData) {
const baseline = horizonData["0"];
if (baseline) {
setBaselinePredictions(baseline);
setPredictions(baseline);
}
}
} else {
// Fallback to single request if batch not available
const overview = await getPredictionsOverviewWithStore(daysAhead, timeRange, 0);
setBaselinePredictions(overview);
setPredictions(overview);
}
setStockPredictions(stock);
} catch (error) {
console.error("Error fetching predictions:", error);
toast({
title: "Error",
description: "Failed to load predictions",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
// Switch predictions from batch data (no API call!)
const switchPredictions = useCallback((horizon: number, simFactor: number) => {
if (!batchData) return;
const horizonData = batchData[horizon.toString()];
if (!horizonData) return;
// Simulation factor is stored as decimal (e.g., 0.1 for 10%)
const simKey = (simFactor / 100).toString();
const newPrediction = horizonData[simKey];
if (newPrediction) {
setPredictions(newPrediction);
if (simFactor === 0) {
setBaselinePredictions(newPrediction);
}
}
}, [batchData]);
// Fetch batch data on initial load or when timeRange changes
useEffect(() => {
fetchBatchData();
setCommittedSimulationFactor(0);
setSimulationFactor(0);
}, [timeRange]);
// Switch predictions when daysAhead changes (instant, from batch)
useEffect(() => {
if (batchData) {
switchPredictions(daysAhead, committedSimulationFactor);
}
}, [daysAhead, batchData, switchPredictions]);
// Switch predictions when simulation factor changes (instant, from batch)
useEffect(() => {
if (batchData) {
switchPredictions(daysAhead, committedSimulationFactor);
}
}, [committedSimulationFactor, batchData, switchPredictions]);
const getConfidenceColor = (confidence: string) => {
switch (confidence) {
case "very_high":
return "bg-emerald-600/20 text-emerald-700 dark:text-emerald-400 border-emerald-600/30";
case "high":
return "bg-green-500/10 text-green-700 dark:text-green-400";
case "medium":
return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400";
case "low":
return "bg-red-500/10 text-red-700 dark:text-red-400";
default:
return "bg-gray-500/10 text-gray-700 dark:text-gray-400";
}
};
const getConfidenceLabel = (confidence: string) => {
switch (confidence) {
case "very_high":
return "Very High";
case "high":
return "High";
case "medium":
return "Medium";
case "low":
return "Low";
default:
return confidence;
}
};
// Combine baseline and simulated data for overlay chart
const chartData = useMemo(() => {
if (!predictions?.sales?.dailyPredictions) return [];
const baselineData = baselinePredictions?.sales?.dailyPredictions || [];
const simulatedDailyData = predictions.sales.dailyPredictions;
return simulatedDailyData.map((d: any, idx: number) => ({
...d,
formattedDate: format(new Date(d.date), "MMM d"),
simulated: d.predicted,
baseline: baselineData[idx]?.predicted ?? d.predicted,
orders: d.predictedOrders || 0,
}));
}, [predictions, baselinePredictions]);
// Keep simulatedData for export compatibility
const simulatedData = chartData;
const handleExportCSV = () => {
if (!simulatedData.length) return;
// Create CSV headers
const headers = ["Date", "Baseline Revenue", "Simulated Revenue", "Orders", "Confidence"];
// Create CSV rows
const rows = simulatedData.map(d => [
format(new Date(d.date), "yyyy-MM-dd"),
d.baseline?.toFixed(2) || "",
d.simulated?.toFixed(2) || "",
d.orders || "",
predictions?.sales?.confidence || "unknown"
]);
// Combine headers and rows
const csvContent = [
headers.join(","),
...rows.map(row => row.join(","))
].join("\n");
// Create download link
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `predictions_export_${format(new Date(), "yyyyMMdd")}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (loading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</CardHeader>
<CardContent>
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</CardContent>
</Card>
);
}
if (!predictions) {
return (
<Card>
<CardHeader>
<CardTitle>Predictions</CardTitle>
<CardDescription>
Forecast future sales, demand, and stock levels
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-8">
No prediction data available. Need more historical data.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Predictions & Forecasting
</CardTitle>
<CardDescription>
{predictions.sales.aiModel?.used
? "AI neural network + statistical models for sales, demand, and inventory"
: "AI-powered predictions for sales, demand, and inventory"}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Select
value={daysAhead.toString()}
onValueChange={(value) => setDaysAhead(parseInt(value))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 days</SelectItem>
<SelectItem value="14">14 days</SelectItem>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="60">60 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={fetchBatchData}
disabled={loading || isSimulating}
>
<RefreshCw
className={`h-4 w-4 ${loading || isSimulating ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2 mb-6">
<Button
variant={activeTab === "overview" ? "default" : "outline"}
onClick={() => setActiveTab("overview")}
size="sm"
>
Overview
</Button>
<Button
variant={activeTab === "stock" ? "default" : "outline"}
onClick={() => setActiveTab("stock")}
size="sm"
>
Stock Predictions
</Button>
</div>
{activeTab === "overview" && (
<div className="space-y-6">
{/* Sales Predictions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<TooltipProvider delayDuration={0}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<DollarSign className="h-4 w-4" />
Revenue Prediction
</CardTitle>
</CardHeader>
<CardContent>
{predictions.sales.predicted !== null ? (
<div className="space-y-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-2xl font-bold w-fit cursor-help">
<CountUp
end={predictions.sales.predicted}
duration={1.5}
separator=","
decimals={2}
prefix="£"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Predicted daily average revenue for the next {daysAhead} days</p>
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-2 flex-wrap">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help">
<Badge
className={getConfidenceColor(
predictions.sales.confidence,
)}
>
{getConfidenceLabel(predictions.sales.confidence)} Confidence
{predictions.sales.confidenceScore !== undefined && (
<span className="ml-1 opacity-75">
({Math.round(predictions.sales.confidenceScore * 100)}%)
</span>
)}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Based on data consistency, historical accuracy, and model agreement</p>
</TooltipContent>
</Tooltip>
{predictions.sales.aiModel?.used && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help">
<Badge variant="outline" className="bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/30">
🤖 AI Powered
{predictions.sales.aiModel.modelAccuracy !== undefined && (
<span className="ml-1 opacity-75">
({Math.round(predictions.sales.aiModel.modelAccuracy * 100)}%)
</span>
)}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Predictions generated using a Deep Learning Ensemble Model</p>
</TooltipContent>
</Tooltip>
)}
{predictions.sales.trend && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help">
<Badge
variant="outline"
className={
predictions.sales.trend.direction === "up"
? "text-green-600 border-green-600"
: predictions.sales.trend.direction === "down"
? "text-red-600 border-red-600"
: ""
}
>
{predictions.sales.trend.direction === "up" && (
<TrendingUp className="h-3 w-3 mr-1" />
)}
{predictions.sales.trend.direction === "down" && (
<TrendingDown className="h-3 w-3 mr-1" />
)}
{predictions.sales.trend.direction === "up"
? "Trending Up"
: predictions.sales.trend.direction === "down"
? "Trending Down"
: "Stable"}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Direction of the recent sales trend (slope analysis)</p>
</TooltipContent>
</Tooltip>
)}
<span className="text-xs text-muted-foreground">
Next {daysAhead} days
</span>
</div>
{predictions.sales.predictedOrders && (
<div className="text-sm text-muted-foreground">
~{Math.round(predictions.sales.predictedOrders)}{" "}
orders
</div>
)}
{!predictions.sales.confidenceIntervals &&
predictions.sales.minPrediction &&
predictions.sales.maxPrediction && (
<div className="text-xs text-muted-foreground">
Range: {formatGBP(predictions.sales.minPrediction)} -{" "}
{formatGBP(predictions.sales.maxPrediction)}
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
{predictions.sales.message ||
"Insufficient data for prediction"}
</div>
)}
</CardContent>
</Card>
{/* Model Intelligence Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Brain className="h-4 w-4" />
Model Intelligence
<Tooltip>
<TooltipTrigger>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>Technical details about the active prediction model</p>
</TooltipContent>
</Tooltip>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Architecture</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-sm font-medium flex items-center gap-1 cursor-help">
<Layers className="h-3 w-3 text-purple-500" />
Hybrid Ensemble (Deep Learning)
</span>
</TooltipTrigger>
<TooltipContent>
<p>Combines LSTM Neural Networks with Statistical Methods (Holt-Winters, ARIMA)</p>
</TooltipContent>
</Tooltip>
</div>
{stockPredictions?.predictions && (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Features</span>
<span className="text-xs font-medium bg-secondary px-2 py-1 rounded-md">
Multi-Feature Enabled
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Optimization</span>
<span className="text-sm font-medium flex items-center gap-1">
<Zap className="h-3 w-3 text-amber-500" />
Performance Tuned
</span>
</div>
<div className="pt-2 border-t text-xs text-muted-foreground">
Model automatically retrains with new sales data.
</div>
</div>
</CardContent>
</Card>
</TooltipProvider>
</div>
<Alert className="bg-yellow-500/10 border-yellow-500/30 text-yellow-700 dark:text-yellow-400">
<AlertTriangle className="h-4 w-4 stroke-yellow-700 dark:stroke-yellow-400" />
<AlertTitle>Prediction Accuracy Warning</AlertTitle>
<AlertDescription>
These predictions are estimates based on historical sales data. Actual results may vary due to external factors, market conditions, and unforeseen events. Use these insights as a guide, not a guarantee.
</AlertDescription>
</Alert>
{/* Daily Predictions Chart */}
{predictions.sales.dailyPredictions &&
predictions.sales.dailyPredictions.length > 0 && (
<Card>
<CardHeader className="pb-3 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium">
Daily Revenue Forecast
</CardTitle>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end">
<span className="text-xs font-medium text-muted-foreground">
Simulate Traffic:{" "}
<span className={simulationFactor > 0 ? "text-green-600" : simulationFactor < 0 ? "text-red-600" : ""}>
{simulationFactor > 0 ? "+" : ""}
{simulationFactor}%
</span>
</span>
<Slider
value={[simulationFactor]}
min={-50}
max={50}
step={10}
onValueChange={(val) => setSimulationFactor(val[0])}
onValueCommit={(val) => setCommittedSimulationFactor(val[0])}
className="w-[150px] mt-1.5"
/>
</div>
{simulationFactor !== 0 && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setSimulationFactor(0);
setCommittedSimulationFactor(0);
}}
title="Reset simulation"
>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div>
<Button variant="outline" size="sm" onClick={handleExportCSV} title="Export to CSV">
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full mt-4 relative">
{isSimulating && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorBaseline" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="#8884d8"
stopOpacity={0.6}
/>
<stop
offset="95%"
stopColor="#8884d8"
stopOpacity={0}
/>
</linearGradient>
<linearGradient id="colorSimulated" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="#10b981"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="#10b981"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
<XAxis
dataKey="formattedDate"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `£${value}`}
/>
<RechartsTooltip
contentStyle={{
backgroundColor: "hsl(var(--background))",
borderColor: "hsl(var(--border))",
borderRadius: "var(--radius)",
}}
formatter={(value: number, name: string) => [
formatGBP(value),
name === "baseline" ? "Baseline" : "Simulated"
]}
/>
{/* Always show baseline as solid line */}
<Area
type="monotone"
dataKey="baseline"
stroke="#8884d8"
fillOpacity={committedSimulationFactor !== 0 ? 0.3 : 1}
fill="url(#colorBaseline)"
strokeWidth={committedSimulationFactor !== 0 ? 1 : 2}
/>
{/* Show simulated line when simulation is active */}
{committedSimulationFactor !== 0 && (
<Area
type="monotone"
dataKey="simulated"
stroke="#10b981"
fillOpacity={0.6}
fill="url(#colorSimulated)"
strokeDasharray="5 5"
strokeWidth={2}
/>
)}
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)}
</div>
)}
{activeTab === "stock" && (
<div className="space-y-4">
{stockPredictions && stockPredictions.predictions.length > 0 ? (
<>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{stockPredictions.totalProducts} products tracked
</p>
{stockPredictions.productsNeedingRestock > 0 && (
<p className="text-sm font-medium text-orange-600 dark:text-orange-400 mt-1">
{stockPredictions.productsNeedingRestock} products
need restocking soon
</p>
)}
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Current Stock</TableHead>
<TableHead>Days Until Out</TableHead>
<TableHead>Estimated Date</TableHead>
<TableHead>Confidence</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockPredictions.predictions.map((prediction) => (
<TableRow key={prediction.productId}>
<TableCell className="font-medium">
{prediction.productName}
</TableCell>
<TableCell>
{prediction.currentStock} {prediction.unitType}
</TableCell>
<TableCell>
{prediction.prediction.daysUntilOutOfStock !== null ? (
<div className="space-y-1">
<div className="font-medium">
{prediction.prediction.daysUntilOutOfStock} days
</div>
{prediction.prediction.optimisticDays !== null &&
prediction.prediction.pessimisticDays && (
<div className="text-xs text-muted-foreground">
{prediction.prediction.optimisticDays} -{" "}
{prediction.prediction.pessimisticDays} days
</div>
)}
</div>
) : (
"N/A"
)}
</TableCell>
<TableCell>
{prediction.prediction.estimatedDate ? (
<div className="space-y-1">
<div>
{format(
new Date(
prediction.prediction.estimatedDate,
),
"MMM d, yyyy",
)}
</div>
{prediction.prediction.optimisticDate &&
prediction.prediction.pessimisticDate && (
<div className="text-xs text-muted-foreground">
{format(
new Date(
prediction.prediction.optimisticDate,
),
"MMM d",
)}{" "}
-{" "}
{format(
new Date(
prediction.prediction.pessimisticDate,
),
"MMM d",
)}
</div>
)}
</div>
) : (
"N/A"
)}
</TableCell>
<TableCell>
<Badge
className={getConfidenceColor(
prediction.prediction.confidence,
)}
>
{getConfidenceLabel(prediction.prediction.confidence)}
</Badge>
</TableCell>
<TableCell>
{prediction.needsRestock ? (
<Badge
variant="destructive"
className="flex items-center gap-1 w-fit"
>
<AlertTriangle className="h-3 w-3" />
Restock Soon
</Badge>
) : (
<Badge variant="outline">OK</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
) : (
<div className="text-center py-8 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No stock predictions available</p>
<p className="text-sm mt-1">
Enable stock tracking on products to get predictions
</p>
</div>
)}
</div>
)}
</CardContent>
</Card >
);
}