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:
265
src/client/components/WeightSummaryCard.tsx
Normal file
265
src/client/components/WeightSummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user