Browse Source

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.
AneoPsy 2 months ago
parent
commit
5216bebca7

+ 50 - 49
frontend/src/components/FilamentTrends.tsx

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

+ 5 - 2
frontend/src/components/MetricToggle.tsx

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

+ 58 - 109
frontend/src/pages/StatsPage.tsx

@@ -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" />
-        </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>
+      {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>
+      ))}
     </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,
       });
     }