mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
refactor(stats): improve Color Distribution card layout and toggle
Use MetricToggle component with new exclude prop instead of inline buttons, default to weight-first order, and redesign chart layout with centered donut, total label, and 2-column legend grid.
This commit is contained in:
@@ -27,7 +27,7 @@ const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'
|
||||
export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filamentTypeMetric, setFilamentTypeMetric] = useState<Metric>('weight');
|
||||
const [colorMetric, setColorMetric] = useState<'weight' | 'prints'>('prints');
|
||||
const [colorMetric, setColorMetric] = useState<Metric>('weight');
|
||||
|
||||
// Calculate daily usage data
|
||||
const dailyData = useMemo(() => {
|
||||
@@ -377,70 +377,71 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
|
||||
<div className="bg-bambu-dark rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium text-bambu-gray">{t('stats.colorDistribution')}</h4>
|
||||
<div className="flex gap-0.5 bg-bambu-dark-secondary rounded-lg p-0.5">
|
||||
<button onClick={() => setColorMetric('prints')}
|
||||
className={`px-2 py-0.5 text-xs rounded-md transition-colors ${colorMetric === 'prints' ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'}`}>
|
||||
{t('stats.filamentByPrints')}
|
||||
</button>
|
||||
<button onClick={() => setColorMetric('weight')}
|
||||
className={`px-2 py-0.5 text-xs rounded-md transition-colors ${colorMetric === 'weight' ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'}`}>
|
||||
{t('stats.filamentByWeight')}
|
||||
</button>
|
||||
</div>
|
||||
<MetricToggle value={colorMetric} onChange={setColorMetric} exclude={['time']} />
|
||||
</div>
|
||||
{colorData.length > 0 ? (() => {
|
||||
const colorTotal = colorData.reduce((sum, e) => sum + e.value, 0);
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width={120} height={120}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={colorData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={30}
|
||||
outerRadius={50}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{colorData.map((entry, index) => (
|
||||
<Cell key={`color-${index}`} fill={entry.hex} stroke="#1a1a1a" strokeWidth={1} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#2d2d2d',
|
||||
border: '1px solid #3d3d3d',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value) => [
|
||||
colorMetric === 'weight' ? formatWeight(Number(value ?? 0)) : `${value ?? 0}`,
|
||||
colorMetric === 'weight' ? t('stats.filamentByWeight') : t('stats.filamentByPrints'),
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex-1 space-y-1 overflow-hidden max-h-[120px] overflow-y-auto">
|
||||
<div>
|
||||
<div className="relative mx-auto" style={{ width: 160, height: 160 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={colorData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={70}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{colorData.map((entry, index) => (
|
||||
<Cell key={`color-${index}`} fill={entry.hex} stroke="#1a1a1a" strokeWidth={1} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#2d2d2d',
|
||||
border: '1px solid #3d3d3d',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value) => [
|
||||
colorMetric === 'weight' ? formatWeight(Number(value ?? 0)) : `${value ?? 0}`,
|
||||
colorMetric === 'weight' ? t('stats.filamentByWeight') : t('stats.filamentByPrints'),
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">
|
||||
{colorMetric === 'weight' ? formatWeight(colorTotal) : colorTotal}
|
||||
</span>
|
||||
<span className="text-[10px] text-bambu-gray">
|
||||
{colorData.length} {colorData.length === 1 ? 'color' : 'colors'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
|
||||
{colorData.slice(0, 8).map((entry) => {
|
||||
const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0;
|
||||
return (
|
||||
<div key={entry.hex} className="flex items-center gap-2 text-sm">
|
||||
<div className="w-3 h-3 rounded-full flex-shrink-0 border border-white/20"
|
||||
<div key={entry.hex} className="flex items-center gap-1.5 text-xs min-w-0">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 border border-white/20"
|
||||
style={{ backgroundColor: entry.hex }} />
|
||||
<span className="text-bambu-gray flex-shrink-0 text-xs">
|
||||
{colorMetric === 'weight' ? formatWeight(entry.value) : entry.value} · {percent}%
|
||||
<span className="text-bambu-gray truncate">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{colorData.length > 8 && (
|
||||
<p className="text-xs text-bambu-gray">+{colorData.length - 8} more</p>
|
||||
)}
|
||||
</div>
|
||||
{colorData.length > 8 && (
|
||||
<p className="text-[10px] text-bambu-gray mt-1 text-center">+{colorData.length - 8} more</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<div className="h-[120px] flex items-center justify-center text-bambu-gray">
|
||||
<div className="h-[160px] flex items-center justify-center text-bambu-gray">
|
||||
{t('stats.noColorData')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,9 +7,10 @@ const METRICS: Metric[] = ['weight', 'prints', 'time'];
|
||||
interface MetricToggleProps {
|
||||
value: Metric;
|
||||
onChange: (metric: Metric) => void;
|
||||
exclude?: Metric[];
|
||||
}
|
||||
|
||||
export function MetricToggle({ value, onChange }: MetricToggleProps) {
|
||||
export function MetricToggle({ value, onChange, exclude }: MetricToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const labels: Record<Metric, string> = {
|
||||
@@ -18,9 +19,11 @@ export function MetricToggle({ value, onChange }: MetricToggleProps) {
|
||||
time: t('stats.filamentByTime'),
|
||||
};
|
||||
|
||||
const metrics = exclude ? METRICS.filter(m => !exclude.includes(m)) : METRICS;
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 bg-bambu-dark rounded-lg p-0.5">
|
||||
{METRICS.map(m => (
|
||||
{metrics.map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => onChange(m)}
|
||||
|
||||
@@ -132,62 +132,29 @@ function QuickStatsWidget({
|
||||
currency: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = [
|
||||
{ icon: Package, color: 'text-bambu-green', label: t('stats.totalPrints'), value: `${stats?.total_prints || 0}` },
|
||||
{ icon: Clock, color: 'text-blue-400', label: t('stats.printTime'), value: `${stats?.total_print_time_hours.toFixed(1) || 0}h` },
|
||||
{ icon: Package, color: 'text-orange-400', label: t('stats.filamentUsed'), value: formatWeight(stats?.total_filament_grams || 0) },
|
||||
{ icon: DollarSign, color: 'text-green-400', label: t('stats.filamentCost'), value: `${currency} ${stats?.total_cost.toFixed(2) || '0.00'}` },
|
||||
{ icon: Zap, color: 'text-yellow-400', label: t('stats.energyUsed'), value: `${stats?.total_energy_kwh.toFixed(3) || '0.000'} kWh` },
|
||||
{ icon: DollarSign, color: 'text-yellow-500', label: t('stats.energyCost'), value: `${currency} ${stats?.total_energy_cost.toFixed(2) || '0.00'}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
|
||||
<Package className="w-5 h-5" />
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg bg-bambu-dark ${item.color}`}>
|
||||
<item.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">{item.label}</p>
|
||||
<p className="text-xl font-bold text-white">{item.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.totalPrints')}</p>
|
||||
<p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-bambu-dark text-blue-400">
|
||||
<Clock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.printTime')}</p>
|
||||
<p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-bambu-dark text-orange-400">
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.filamentUsed')}</p>
|
||||
<p className="text-xl font-bold text-white">{formatWeight(stats?.total_filament_grams || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-bambu-dark text-green-400">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.filamentCost')}</p>
|
||||
<p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-bambu-dark text-yellow-400">
|
||||
<Zap className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.energyUsed')}</p>
|
||||
<p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(3) || '0.000'} kWh</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-bambu-dark text-yellow-500">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.energyCost')}</p>
|
||||
<p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -498,13 +465,14 @@ function PrinterStatsWidget({
|
||||
}));
|
||||
}, [archives, habitsMetric]);
|
||||
|
||||
const pUnit = printerMetric === 'weight' ? 'g' : printerMetric === 'time' ? 'h' : '';
|
||||
const metricStyle = (m: Metric) => ({
|
||||
unit: m === 'weight' ? 'g' : m === 'time' ? 'h' : '',
|
||||
color: m === 'weight' ? '#00ae42' : m === 'time' ? '#3b82f6' : '#f59e0b',
|
||||
});
|
||||
const ps = metricStyle(printerMetric);
|
||||
const pLabel = printerMetric === 'weight' ? t('stats.filamentByWeight') : printerMetric === 'time' ? t('stats.hours') : t('common.prints');
|
||||
const pColor = printerMetric === 'weight' ? '#00ae42' : printerMetric === 'time' ? '#3b82f6' : '#f59e0b';
|
||||
|
||||
const hUnit = habitsMetric === 'weight' ? 'g' : habitsMetric === 'time' ? 'h' : '';
|
||||
const hs = metricStyle(habitsMetric);
|
||||
const hLabel = habitsMetric === 'weight' ? t('stats.avgWeight') : habitsMetric === 'time' ? t('stats.avgTime') : t('stats.avgPrints');
|
||||
const hColor = habitsMetric === 'weight' ? '#00ae42' : habitsMetric === 'time' ? '#3b82f6' : '#f59e0b';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -518,16 +486,16 @@ function PrinterStatsWidget({
|
||||
<ResponsiveContainer width="100%" height={Math.max(140, printerData.length * 40)}>
|
||||
<BarChart data={printerData} layout="vertical" margin={{ left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
|
||||
<XAxis type="number" stroke="#9ca3af" tick={{ fontSize: 11 }} unit={pUnit} />
|
||||
<XAxis type="number" stroke="#9ca3af" tick={{ fontSize: 11 }} unit={ps.unit} />
|
||||
<YAxis type="category" dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} width={100} />
|
||||
<Tooltip
|
||||
contentStyle={RECHARTS_TOOLTIP_STYLE}
|
||||
formatter={(v: number | undefined) => [
|
||||
printerMetric === 'weight' ? formatWeight(Number(v ?? 0)) : `${v ?? 0}${pUnit}`,
|
||||
printerMetric === 'weight' ? formatWeight(Number(v ?? 0)) : `${v ?? 0}${ps.unit}`,
|
||||
pLabel,
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="value" fill={pColor} radius={[0, 4, 4, 0]} />
|
||||
<Bar dataKey="value" fill={ps.color} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
@@ -565,9 +533,9 @@ function PrinterStatsWidget({
|
||||
<BarChart data={habitsData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
|
||||
<XAxis dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} />
|
||||
<YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} unit={hUnit} />
|
||||
<Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} formatter={(v: number | undefined) => [`${v ?? 0}${hUnit}`, hLabel]} />
|
||||
<Bar dataKey="avg" fill={hColor} radius={[4, 4, 0, 0]} />
|
||||
<YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} unit={hs.unit} />
|
||||
<Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} formatter={(v: number | undefined) => [`${v ?? 0}${hs.unit}`, hLabel]} />
|
||||
<Bar dataKey="avg" fill={hs.color} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
@@ -715,60 +683,41 @@ function RecordsWidget({ archives, currency }: { archives: Archive[]; currency:
|
||||
|
||||
if (archives.length === 0) return result;
|
||||
|
||||
// Longest print
|
||||
let longestArchive: Archive | null = null;
|
||||
let longestTime = 0;
|
||||
archives.forEach(a => {
|
||||
if (a.actual_time_seconds && a.actual_time_seconds > longestTime) {
|
||||
longestTime = a.actual_time_seconds;
|
||||
longestArchive = a;
|
||||
}
|
||||
});
|
||||
if (longestArchive && longestTime > 0) {
|
||||
// Find the archive with the highest value for a given field
|
||||
const findMax = (getter: (a: Archive) => number | null | undefined): { archive: Archive | null; value: number } => {
|
||||
let best: Archive | null = null;
|
||||
let bestVal = 0;
|
||||
archives.forEach(a => {
|
||||
const v = getter(a);
|
||||
if (v && v > bestVal) { bestVal = v; best = a; }
|
||||
});
|
||||
return { archive: best, value: bestVal };
|
||||
};
|
||||
|
||||
const longest = findMax(a => a.actual_time_seconds);
|
||||
if (longest.archive) {
|
||||
result.push({
|
||||
icon: Clock,
|
||||
iconColor: 'text-blue-400',
|
||||
label: t('stats.longestPrint'),
|
||||
value: formatDuration(longestTime),
|
||||
detail: (longestArchive as Archive).print_name || null,
|
||||
icon: Clock, iconColor: 'text-blue-400', label: t('stats.longestPrint'),
|
||||
value: formatDuration(longest.value),
|
||||
detail: longest.archive.print_name || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Heaviest print
|
||||
let heaviestArchive: Archive | null = null;
|
||||
let heaviestWeight = 0;
|
||||
archives.forEach(a => {
|
||||
if (a.filament_used_grams && a.filament_used_grams > heaviestWeight) {
|
||||
heaviestWeight = a.filament_used_grams;
|
||||
heaviestArchive = a;
|
||||
}
|
||||
});
|
||||
if (heaviestArchive && heaviestWeight > 0) {
|
||||
const heaviest = findMax(a => a.filament_used_grams);
|
||||
if (heaviest.archive) {
|
||||
result.push({
|
||||
icon: Package,
|
||||
iconColor: 'text-orange-400',
|
||||
label: t('stats.heaviestPrint'),
|
||||
value: formatWeight(heaviestWeight),
|
||||
detail: (heaviestArchive as Archive).print_name || null,
|
||||
icon: Package, iconColor: 'text-orange-400', label: t('stats.heaviestPrint'),
|
||||
value: formatWeight(heaviest.value),
|
||||
detail: heaviest.archive.print_name || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Most expensive print
|
||||
let costliestArchive: Archive | null = null;
|
||||
let highestCost = 0;
|
||||
archives.forEach(a => {
|
||||
if (a.cost && a.cost > highestCost) {
|
||||
highestCost = a.cost;
|
||||
costliestArchive = a;
|
||||
}
|
||||
});
|
||||
if (costliestArchive && highestCost > 0) {
|
||||
const costliest = findMax(a => a.cost);
|
||||
if (costliest.archive) {
|
||||
result.push({
|
||||
icon: DollarSign,
|
||||
iconColor: 'text-green-400',
|
||||
label: t('stats.mostExpensivePrint'),
|
||||
value: `${currency}${highestCost.toFixed(2)}`,
|
||||
detail: (costliestArchive as Archive).print_name || null,
|
||||
icon: DollarSign, iconColor: 'text-green-400', label: t('stats.mostExpensivePrint'),
|
||||
value: `${currency}${costliest.value.toFixed(2)}`,
|
||||
detail: costliest.archive.print_name || null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user