Redesign WeightSummaryCard stats from a disconnected 4-column grid to a compact legend-style list with color dots, percentages, and a divider before the total row. Switch chart and legend colors to a neutral gray palette. Add a currency selector to settings (USD, EUR, GBP, JPY, CAD, AUD) that changes the displayed symbol across the app. This is visual only — no value conversion is performed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
284 lines
7.2 KiB
TypeScript
284 lines
7.2 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Cell,
|
|
Label,
|
|
Pie,
|
|
PieChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
} from "recharts";
|
|
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
|
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
|
import { LucideIcon } from "../lib/iconData";
|
|
|
|
const CATEGORY_COLORS = [
|
|
"#374151",
|
|
"#4b5563",
|
|
"#6b7280",
|
|
"#7f8a94",
|
|
"#9ca3af",
|
|
"#b0b7bf",
|
|
"#c4c9cf",
|
|
"#d1d5db",
|
|
"#dfe2e6",
|
|
"#e5e7eb",
|
|
];
|
|
|
|
const CLASSIFICATION_COLORS: Record<string, string> = {
|
|
base: "#6b7280",
|
|
worn: "#9ca3af",
|
|
consumable: "#d1d5db",
|
|
};
|
|
|
|
const CLASSIFICATION_LABELS: Record<string, string> = {
|
|
base: "Base Weight",
|
|
worn: "Worn",
|
|
consumable: "Consumable",
|
|
};
|
|
|
|
type ViewMode = "category" | "classification";
|
|
const VIEW_MODES: ViewMode[] = ["category", "classification"];
|
|
|
|
interface ChartDatum {
|
|
name: string;
|
|
weight: number;
|
|
percent: number;
|
|
classificationKey?: string;
|
|
}
|
|
|
|
interface WeightSummaryCardProps {
|
|
items: SetupItemWithCategory[];
|
|
}
|
|
|
|
function buildCategoryChartData(items: SetupItemWithCategory[]): ChartDatum[] {
|
|
const groups = new Map<string, number>();
|
|
for (const item of items) {
|
|
const current = groups.get(item.categoryName) ?? 0;
|
|
groups.set(item.categoryName, current + (item.weightGrams ?? 0));
|
|
}
|
|
const total = items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
|
return Array.from(groups.entries())
|
|
.filter(([, weight]) => weight > 0)
|
|
.map(([name, weight]) => ({
|
|
name,
|
|
weight,
|
|
percent: total > 0 ? weight / total : 0,
|
|
}));
|
|
}
|
|
|
|
function buildClassificationChartData(
|
|
items: SetupItemWithCategory[],
|
|
): ChartDatum[] {
|
|
const groups: Record<string, number> = {
|
|
base: 0,
|
|
worn: 0,
|
|
consumable: 0,
|
|
};
|
|
for (const item of items) {
|
|
groups[item.classification] += item.weightGrams ?? 0;
|
|
}
|
|
const total = Object.values(groups).reduce((a, b) => a + b, 0);
|
|
return Object.entries(groups)
|
|
.filter(([, weight]) => weight > 0)
|
|
.map(([key, weight]) => ({
|
|
name: CLASSIFICATION_LABELS[key] ?? key,
|
|
weight,
|
|
percent: total > 0 ? weight / total : 0,
|
|
classificationKey: key,
|
|
}));
|
|
}
|
|
|
|
function CustomTooltip({
|
|
active,
|
|
payload,
|
|
unit,
|
|
}: {
|
|
active?: boolean;
|
|
payload?: Array<{ payload: ChartDatum }>;
|
|
unit: WeightUnit;
|
|
}) {
|
|
if (!active || !payload?.length) return null;
|
|
const { name, weight, percent } = payload[0].payload;
|
|
return (
|
|
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-sm">
|
|
<p className="font-medium text-gray-900">{name}</p>
|
|
<p className="text-gray-600">
|
|
{formatWeight(weight, unit)} ({(percent * 100).toFixed(1)}%)
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LegendRow({
|
|
color,
|
|
label,
|
|
weight,
|
|
unit,
|
|
percent,
|
|
}: {
|
|
color: string;
|
|
label: string;
|
|
weight: number;
|
|
unit: WeightUnit;
|
|
percent?: number;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-3 py-1.5">
|
|
<span
|
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
<span className="text-sm text-gray-600 flex-1">{label}</span>
|
|
<span className="text-sm font-semibold text-gray-900 tabular-nums">
|
|
{formatWeight(weight, unit)}
|
|
</span>
|
|
{percent != null && (
|
|
<span className="text-xs text-gray-400 w-10 text-right tabular-nums">
|
|
{(percent * 100).toFixed(0)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|
const unit = useWeightUnit();
|
|
const [viewMode, setViewMode] = useState<ViewMode>("category");
|
|
|
|
const baseWeight = items.reduce(
|
|
(sum, i) =>
|
|
i.classification === "base" ? sum + (i.weightGrams ?? 0) : sum,
|
|
0,
|
|
);
|
|
const wornWeight = items.reduce(
|
|
(sum, i) =>
|
|
i.classification === "worn" ? sum + (i.weightGrams ?? 0) : sum,
|
|
0,
|
|
);
|
|
const consumableWeight = items.reduce(
|
|
(sum, i) =>
|
|
i.classification === "consumable" ? sum + (i.weightGrams ?? 0) : sum,
|
|
0,
|
|
);
|
|
const totalWeight = baseWeight + wornWeight + consumableWeight;
|
|
|
|
const chartData =
|
|
viewMode === "category"
|
|
? buildCategoryChartData(items)
|
|
: buildClassificationChartData(items);
|
|
|
|
const colors =
|
|
viewMode === "category"
|
|
? CATEGORY_COLORS
|
|
: chartData.map(
|
|
(d) => CLASSIFICATION_COLORS[d.classificationKey ?? ""] ?? "#6366f1",
|
|
);
|
|
|
|
if (totalWeight === 0) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
|
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
|
Weight Summary
|
|
</h3>
|
|
<p className="text-sm text-gray-400">No weight data to display</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
|
{/* Header with pill toggle */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-sm font-medium text-gray-700">Weight Summary</h3>
|
|
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
|
{VIEW_MODES.map((mode) => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
onClick={() => setViewMode(mode)}
|
|
className={`px-2.5 py-0.5 text-xs rounded-full transition-colors ${
|
|
viewMode === mode
|
|
? "bg-white text-gray-700 shadow-sm font-medium"
|
|
: "text-gray-400 hover:text-gray-600"
|
|
}`}
|
|
>
|
|
{mode === "category" ? "Category" : "Classification"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content: chart + subtotals side by side */}
|
|
<div className="flex items-center gap-8">
|
|
{/* Donut chart */}
|
|
<div className="flex-shrink-0" style={{ width: 180, height: 180 }}>
|
|
<ResponsiveContainer width="100%" height={180}>
|
|
<PieChart>
|
|
<Pie
|
|
data={chartData}
|
|
dataKey="weight"
|
|
nameKey="name"
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={55}
|
|
outerRadius={80}
|
|
paddingAngle={2}
|
|
>
|
|
{chartData.map((entry, index) => (
|
|
<Cell key={entry.name} fill={colors[index % colors.length]} />
|
|
))}
|
|
<Label
|
|
value={formatWeight(totalWeight, unit)}
|
|
position="center"
|
|
style={{
|
|
fontSize: "14px",
|
|
fontWeight: 600,
|
|
fill: "#374151",
|
|
}}
|
|
/>
|
|
</Pie>
|
|
<Tooltip content={<CustomTooltip unit={unit} />} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Weight legend */}
|
|
<div className="flex-1 flex flex-col justify-center min-w-0">
|
|
<LegendRow
|
|
color="#6b7280"
|
|
label="Base Weight"
|
|
weight={baseWeight}
|
|
unit={unit}
|
|
percent={totalWeight > 0 ? baseWeight / totalWeight : undefined}
|
|
/>
|
|
<LegendRow
|
|
color="#9ca3af"
|
|
label="Worn"
|
|
weight={wornWeight}
|
|
unit={unit}
|
|
percent={totalWeight > 0 ? wornWeight / totalWeight : undefined}
|
|
/>
|
|
<LegendRow
|
|
color="#d1d5db"
|
|
label="Consumable"
|
|
weight={consumableWeight}
|
|
unit={unit}
|
|
percent={totalWeight > 0 ? consumableWeight / totalWeight : undefined}
|
|
/>
|
|
<div className="border-t border-gray-200 mt-1.5 pt-1.5">
|
|
<div className="flex items-center gap-3 py-1.5">
|
|
<LucideIcon name="sigma" size={10} className="text-gray-400 shrink-0 ml-0.5" />
|
|
<span className="text-sm font-medium text-gray-700 flex-1">Total</span>
|
|
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
|
{formatWeight(totalWeight, unit)}
|
|
</span>
|
|
<span className="w-10" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|