Add baseline comparison for discount simulator

This commit is contained in:
2026-01-15 16:26:28 -05:00
parent 738ed94ad5
commit 0ffd02e22e
2 changed files with 133 additions and 25 deletions

View File

@@ -1,9 +1,12 @@
import { differenceInDays } from "date-fns";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { formatCurrency } from "@/utils/productUtils"; import { formatCurrency } from "@/utils/productUtils";
import { DiscountSimulationResponse } from "@/types/discount-simulator"; import { DiscountSimulationResponse } from "@/types/discount-simulator";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Bookmark, X } from "lucide-react";
// Utility function to interpolate between two colors // Utility function to interpolate between two colors
const interpolateColor = (color1: [number, number, number], color2: [number, number, number], ratio: number): [number, number, number] => { const interpolateColor = (color1: [number, number, number], color2: [number, number, number], ratio: number): [number, number, number] => {
@@ -49,6 +52,37 @@ const getProfitPercentageColor = (percentage: number): string => {
interface SummaryCardProps { interface SummaryCardProps {
result?: DiscountSimulationResponse; result?: DiscountSimulationResponse;
isLoading: boolean; isLoading: boolean;
baselineResult?: DiscountSimulationResponse;
onSaveAsBaseline?: () => void;
onClearBaseline?: () => void;
}
function calculateAnnualizedProfitDiff(
current: DiscountSimulationResponse,
baseline: DiscountSimulationResponse
): number | null {
const currentTotals = current.totals;
const baselineTotals = baseline.totals;
if (!currentTotals || !baselineTotals) return null;
// Calculate days in the current simulation period
const startDate = new Date(current.dateRange.start);
const endDate = new Date(current.dateRange.end);
const daysInPeriod = differenceInDays(endDate, startDate) + 1; // +1 to include both start and end days
if (daysInPeriod <= 0) return null;
// Profit difference per order
const profitDiffPerOrder = currentTotals.weightedProfitAmount - baselineTotals.weightedProfitAmount;
// Total profit difference for the period
const totalProfitDiff = profitDiffPerOrder * currentTotals.orders;
// Annualize: scale to 365 days
const annualizedDiff = (totalProfitDiff / daysInPeriod) * 365;
return annualizedDiff;
} }
const formatPercent = (value: number) => { const formatPercent = (value: number) => {
@@ -56,16 +90,23 @@ const formatPercent = (value: number) => {
return `${(value * 100).toFixed(2)}%`; return `${(value * 100).toFixed(2)}%`;
}; };
export function SummaryCard({ result, isLoading }: SummaryCardProps) { export function SummaryCard({
result,
isLoading,
baselineResult,
onSaveAsBaseline,
onClearBaseline
}: SummaryCardProps) {
if (isLoading && !result) { if (isLoading && !result) {
return ( return (
<Card className="pt-6"> <Card>
<CardContent> <CardContent className="py-4">
<div className="h-72 flex flex-col items-center justify-center gap-8"> <div className="flex items-center justify-center gap-8">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<Skeleton className="h-4 w-24 mx-auto" /> <Skeleton className="h-4 w-24 mx-auto" />
<Skeleton className="h-10 w-20 mx-auto" /> <Skeleton className="h-8 w-20 mx-auto" />
</div> </div>
<Separator orientation="vertical" className="h-12" />
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<Skeleton className="h-4 w-32 mx-auto" /> <Skeleton className="h-4 w-32 mx-auto" />
<Skeleton className="h-8 w-24 mx-auto" /> <Skeleton className="h-8 w-24 mx-auto" />
@@ -89,32 +130,84 @@ export function SummaryCard({ result, isLoading }: SummaryCardProps) {
: "destructive" : "destructive"
: "secondary"; : "secondary";
// Calculate annualized profit difference if baseline exists
const annualizedDiff = result && baselineResult
? calculateAnnualizedProfitDiff(result, baselineResult)
: null;
const hasBaseline = !!baselineResult;
const isPositiveDiff = annualizedDiff !== null && annualizedDiff >= 0;
return ( return (
<Card className="pt-6"> <Card>
<CardContent> <CardContent className="py-4">
<div className="h-72 flex flex-col items-center justify-center gap-5"> <div className="flex items-center justify-center gap-6">
{/* Weighted Average Profit */}
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground mb-3">Weighted Average Profit</p> <p className="text-sm text-muted-foreground mb-2">Weighted Average Profit</p>
{totals ? ( {totals ? (
<span <span
className="inline-block px-4 py-2 rounded text-white font-semibold text-2xl" className="inline-block px-3 py-1.5 rounded text-white font-semibold text-lg"
style={{ backgroundColor: profitPercentageColor }} style={{ backgroundColor: profitPercentageColor }}
> >
{weightedProfitPercent} {weightedProfitPercent}
</span> </span>
) : ( ) : (
<Badge variant={weightedBadgeVariant} className="text-2xl py-2 px-4 font-semibold"> <Badge variant={weightedBadgeVariant} className="text-lg py-1.5 px-3 font-semibold">
{weightedProfitPercent} {weightedProfitPercent}
</Badge> </Badge>
)} )}
</div> </div>
<Separator orientation="horizontal" />
<Separator orientation="vertical" className="h-12" />
{/* Weighted Profit Per Order */}
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground mb-3">Weighted Profit Per Order</p> <p className="text-sm text-muted-foreground mb-2">Weighted Profit Per Order</p>
<Badge variant="secondary" className="text-2xl py-2 px-4 font-semibold"> <Badge variant="secondary" className="text-lg py-1.5 px-3 font-semibold">
{weightedProfitAmount} {weightedProfitAmount}
</Badge> </Badge>
</div> </div>
{/* Baseline comparison section */}
{hasBaseline && annualizedDiff !== null && (
<>
<Separator orientation="vertical" className="h-12" />
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Annual Change vs Baseline</p>
<span
className={`inline-block px-3 py-1.5 rounded bg-secondary font-semibold text-lg ${
isPositiveDiff ? 'text-green-600' : 'text-red-600'
}`}
>
{isPositiveDiff ? '+' : ''}{formatCurrency(annualizedDiff)}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={onClearBaseline}
>
<X className="h-3 w-3 mr-1" />
Clear
</Button>
</>
)}
{/* Save as baseline button */}
{result && !hasBaseline && (
<>
<Separator orientation="vertical" className="h-12" />
<Button
variant="outline"
size="sm"
onClick={onSaveAsBaseline}
>
<Bookmark className="h-3 w-3 mr-1" />
Set as baseline
</Button>
</>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -62,6 +62,7 @@ export function DiscountSimulator() {
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE); const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
const [pointDollarTouched, setPointDollarTouched] = useState(false); const [pointDollarTouched, setPointDollarTouched] = useState(false);
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined); const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
const [baselineResult, setBaselineResult] = useState<DiscountSimulationResponse | undefined>(undefined);
const [isSimulating, setIsSimulating] = useState(false); const [isSimulating, setIsSimulating] = useState(false);
const [hasLoadedConfig, setHasLoadedConfig] = useState(false); const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
const [loadedFromStorage, setLoadedFromStorage] = useState(false); const [loadedFromStorage, setLoadedFromStorage] = useState(false);
@@ -402,6 +403,16 @@ export function DiscountSimulator() {
} }
}; };
const handleSaveAsBaseline = useCallback(() => {
if (simulationResult) {
setBaselineResult(simulationResult);
}
}, [simulationResult]);
const handleClearBaseline = useCallback(() => {
setBaselineResult(undefined);
}, []);
const resetConfig = useCallback(() => { const resetConfig = useCallback(() => {
skipAutoRunRef.current = true; skipAutoRunRef.current = true;
const defaultRange = getDefaultDateRange(); const defaultRange = getDefaultDateRange();
@@ -478,17 +489,21 @@ export function DiscountSimulator() {
{/* Right Side - Results */} {/* Right Side - Results */}
<div className="space-y-4 min-w-0 flex-1 overflow-hidden"> <div className="space-y-4 min-w-0 flex-1 overflow-hidden">
{/* Top Right - Summary and Chart */} {/* Top - Summary (horizontal) */}
<div className="grid gap-4 lg:grid-cols-[160px,1fr] min-w-0"> <SummaryCard
<div className="w-full min-w-0"> result={simulationResult}
<SummaryCard result={simulationResult} isLoading={isLoading} /> isLoading={isLoading}
</div> baselineResult={baselineResult}
<div className="w-full min-w-0 overflow-hidden"> onSaveAsBaseline={handleSaveAsBaseline}
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} /> onClearBaseline={handleClearBaseline}
</div> />
{/* Chart */}
<div className="w-full min-w-0 overflow-hidden">
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
</div> </div>
{/* Bottom Right - Table */} {/* Table */}
<div className="w-full min-w-0 overflow-hidden"> <div className="w-full min-w-0 overflow-hidden">
<ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} /> <ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
</div> </div>