Files
GearBox/src/client/components/WeightSummaryCard.tsx
Jean-Luc Makiola 9647f5759d feat: redesign weight summary legend and add currency selector
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>
2026-03-16 20:33:07 +01:00

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>
);
}