feat(09-02): add WeightSummaryCard with donut chart and classification subtotals

- Install recharts dependency for donut chart visualization
- Create WeightSummaryCard component with pill toggle (category/classification views)
- Compute base/worn/consumable/total weight subtotals from items array
- Render donut chart with colored segments, center total, and hover tooltips
- Wire WeightSummaryCard into setup detail page below sticky bar
This commit is contained in:
2026-03-16 15:20:41 +01:00
parent 83103251b1
commit d098277797
4 changed files with 344 additions and 1 deletions

View File

@@ -0,0 +1,265 @@
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";
const CATEGORY_COLORS = [
"#6366f1",
"#f59e0b",
"#10b981",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#f97316",
"#ec4899",
"#14b8a6",
"#84cc16",
];
const CLASSIFICATION_COLORS: Record<string, string> = {
base: "#6366f1",
worn: "#f59e0b",
consumable: "#10b981",
};
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 SubtotalColumn({
label,
weight,
unit,
color,
}: {
label: string;
weight: number;
unit: WeightUnit;
color?: string;
}) {
return (
<div className="flex flex-col items-center gap-1">
{color && (
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: color }}
/>
)}
<span className="text-xs text-gray-500">{label}</span>
<span className="text-sm font-semibold text-gray-900">
{formatWeight(weight, unit)}
</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 subtotals columns */}
<div className="flex-1 grid grid-cols-4 gap-4">
<SubtotalColumn
label="Base"
weight={baseWeight}
unit={unit}
color="#6366f1"
/>
<SubtotalColumn
label="Worn"
weight={wornWeight}
unit={unit}
color="#f59e0b"
/>
<SubtotalColumn
label="Consumable"
weight={consumableWeight}
unit={unit}
color="#10b981"
/>
<SubtotalColumn label="Total" weight={totalWeight} unit={unit} />
</div>
</div>
</div>
);
}