Browse Source

Improve Statistics dashboard widgets to utilize space when expanded (#72)
- Add size-aware rendering to Dashboard component, passing widget size
(1/4, 1/2, full) to component render functions
- PrintCalendar: Dynamic cell sizing using ResizeObserver to fill
available width, shows 3/6/12 months based on widget size
- SuccessRateWidget: Per-printer breakdown at larger sizes
- TimeAccuracyWidget: Per-printer breakdown at larger sizes
- FilamentTypesWidget: Grid layout at larger sizes
- FailureAnalysisWidget: More failure reasons at larger sizes
- Bump service worker cache version

maziggy 7 tháng trước cách đây
mục cha
commit
f7f4d88222

+ 2 - 2
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v4';
-const STATIC_CACHE = 'bambuddy-static-v4';
+const CACHE_NAME = 'bambuddy-v15';
+const STATIC_CACHE = 'bambuddy-static-v15';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

+ 9 - 7
frontend/src/components/Dashboard.tsx

@@ -22,7 +22,8 @@ import { Button } from './Button';
 export interface DashboardWidget {
   id: string;
   title: string;
-  component: ReactNode;
+  /** Render function that receives the current size for responsive content */
+  component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);
   defaultVisible?: boolean;
   defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default)
 }
@@ -50,7 +51,7 @@ interface LayoutState {
 function SortableWidget({
   id,
   title,
-  children,
+  component,
   isHidden,
   size,
   onToggleVisibility,
@@ -58,7 +59,7 @@ function SortableWidget({
 }: {
   id: string;
   title: string;
-  children: ReactNode;
+  component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);
   isHidden: boolean;
   size: 1 | 2 | 4;
   onToggleVisibility: () => void;
@@ -127,7 +128,9 @@ function SortableWidget({
         </div>
       </div>
       {/* Widget Content */}
-      <div className="p-4">{children}</div>
+      <div className="p-4">
+        {typeof component === 'function' ? component(size) : component}
+      </div>
     </div>
   );
 }
@@ -329,13 +332,12 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
                 key={widget.id}
                 id={widget.id}
                 title={widget.title}
+                component={widget.component}
                 isHidden={layout.hidden.includes(widget.id)}
                 size={layout.sizes[widget.id] || 2}
                 onToggleVisibility={() => toggleVisibility(widget.id)}
                 onToggleSize={() => toggleSize(widget.id)}
-              >
-                {widget.component}
-              </SortableWidget>
+              />
             ))}
           </div>
         </SortableContext>

+ 106 - 60
frontend/src/components/PrintCalendar.tsx

@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useMemo, useRef, useState, useEffect } from 'react';
 
 interface PrintCalendarProps {
   printDates: string[]; // Array of ISO date strings
@@ -6,6 +6,23 @@ interface PrintCalendarProps {
 }
 
 export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const [containerWidth, setContainerWidth] = useState(0);
+
+  // Measure container width
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+
+    const observer = new ResizeObserver((entries) => {
+      const width = entries[0]?.contentRect.width || 0;
+      setContainerWidth(width);
+    });
+
+    observer.observe(container);
+    return () => observer.disconnect();
+  }, []);
+
   const { weeks, monthLabels, printCounts } = useMemo(() => {
     // Count prints per day
     const counts: Record<string, number> = {};
@@ -68,72 +85,101 @@ export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
 
   const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
 
+  // Calculate cell size based on container width
+  const numWeeks = weeks.length;
+  const dayLabelWidth = 32; // Space for day labels (Mon, Wed, Fri)
+  const gap = 2; // Gap between cells
+  const availableWidth = containerWidth - dayLabelWidth - 16; // 16px padding
+  const calculatedCellSize = numWeeks > 0 ? Math.floor((availableWidth - (numWeeks - 1) * gap) / numWeeks) : 12;
+
+  // Clamp cell size between 8 and 20 pixels
+  const cellSize = Math.max(8, Math.min(20, calculatedCellSize));
+  const fontSize = cellSize <= 10 ? 10 : 12;
+
   return (
-    <div className="overflow-x-auto">
-      {/* Month labels */}
-      <div className="flex mb-1 ml-8">
-        {monthLabels.map(({ month, weekIndex }, i) => (
-          <div
-            key={i}
-            className="text-xs text-bambu-gray"
-            style={{ marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * 14 - 24}px` }}
-          >
-            {month}
+    <div ref={containerRef} className="w-full flex justify-center">
+      {containerWidth > 0 && (
+        <div>
+          {/* Month labels */}
+          <div className="flex mb-1" style={{ marginLeft: dayLabelWidth + 4 }}>
+            {monthLabels.map(({ month, weekIndex }, i) => (
+              <div
+                key={i}
+                className="text-bambu-gray"
+                style={{
+                  fontSize,
+                  marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * (cellSize + gap) - 24}px`,
+                }}
+              >
+                {month}
+              </div>
+            ))}
           </div>
-        ))}
-      </div>
-
-      <div className="flex gap-0.5">
-        {/* Day labels */}
-        <div className="flex flex-col gap-0.5 mr-1">
-          {dayLabels.map((day, i) => (
-            <div
-              key={day}
-              className="h-3 text-xs text-bambu-gray flex items-center"
-              style={{ visibility: i % 2 === 1 ? 'visible' : 'hidden' }}
-            >
-              {day}
-            </div>
-          ))}
-        </div>
 
-        {/* Calendar grid */}
-        {weeks.map((week, weekIndex) => (
-          <div key={weekIndex} className="flex flex-col gap-0.5">
-            {[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
-              const day = week.find((d) => d.getDay() === dayOfWeek);
-              if (!day) {
-                return <div key={dayOfWeek} className="w-3 h-3" />;
-              }
+          <div className="flex" style={{ gap }}>
+            {/* Day labels */}
+            <div className="flex flex-col" style={{ gap, marginRight: 4, width: dayLabelWidth }}>
+              {dayLabels.map((day, i) => (
+                <div
+                  key={day}
+                  className="text-bambu-gray flex items-center"
+                  style={{
+                    width: dayLabelWidth,
+                    height: cellSize,
+                    fontSize,
+                    visibility: i % 2 === 1 ? 'visible' : 'hidden',
+                  }}
+                >
+                  {day}
+                </div>
+              ))}
+            </div>
 
-              const dateStr = day.toISOString().split('T')[0];
-              const count = printCounts[dateStr] || 0;
-              const isToday = dateStr === new Date().toISOString().split('T')[0];
+            {/* Calendar grid */}
+            {weeks.map((week, weekIdx) => (
+              <div key={weekIdx} className="flex flex-col" style={{ gap }}>
+                {[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
+                  const day = week.find((d) => d.getDay() === dayOfWeek);
+                  if (!day) {
+                    return (
+                      <div
+                        key={dayOfWeek}
+                        style={{ width: cellSize, height: cellSize }}
+                      />
+                    );
+                  }
+
+                  const dateStr = day.toISOString().split('T')[0];
+                  const count = printCounts[dateStr] || 0;
+                  const isToday = dateStr === new Date().toISOString().split('T')[0];
+
+                  return (
+                    <div
+                      key={dayOfWeek}
+                      className={`rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
+                      style={{ width: cellSize, height: cellSize }}
+                      title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
+                    />
+                  );
+                })}
+              </div>
+            ))}
+          </div>
 
-              return (
-                <div
-                  key={dayOfWeek}
-                  className={`w-3 h-3 rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
-                  title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
-                />
-              );
-            })}
+          {/* Legend */}
+          <div className="flex items-center gap-2 mt-3 text-bambu-gray" style={{ fontSize }}>
+            <span>Less</span>
+            <div className="flex" style={{ gap }}>
+              <div className="rounded-sm bg-bambu-dark" style={{ width: cellSize, height: cellSize }} />
+              <div className="rounded-sm bg-bambu-green/30" style={{ width: cellSize, height: cellSize }} />
+              <div className="rounded-sm bg-bambu-green/50" style={{ width: cellSize, height: cellSize }} />
+              <div className="rounded-sm bg-bambu-green/75" style={{ width: cellSize, height: cellSize }} />
+              <div className="rounded-sm bg-bambu-green" style={{ width: cellSize, height: cellSize }} />
+            </div>
+            <span>More</span>
           </div>
-        ))}
-      </div>
-
-      {/* Legend */}
-      <div className="flex items-center gap-2 mt-3 text-xs text-bambu-gray">
-        <span>Less</span>
-        <div className="flex gap-0.5">
-          <div className="w-3 h-3 rounded-sm bg-bambu-dark" />
-          <div className="w-3 h-3 rounded-sm bg-bambu-green/30" />
-          <div className="w-3 h-3 rounded-sm bg-bambu-green/50" />
-          <div className="w-3 h-3 rounded-sm bg-bambu-green/75" />
-          <div className="w-3 h-3 rounded-sm bg-bambu-green" />
         </div>
-        <span>More</span>
-      </div>
+      )}
     </div>
   );
 }

+ 187 - 73
frontend/src/pages/StatsPage.tsx

@@ -103,48 +103,83 @@ function QuickStatsWidget({
 
 function SuccessRateWidget({
   stats,
+  printerMap,
+  size = 1,
 }: {
   stats: {
     total_prints: number;
     successful_prints: number;
     failed_prints: number;
+    prints_by_printer: Record<string, number>;
   } | undefined;
+  printerMap: Map<string, string>;
+  size?: 1 | 2 | 4;
 }) {
   const successRate = stats?.total_prints
     ? Math.round((stats.successful_prints / stats.total_prints) * 100)
     : 0;
 
+  // Scale gauge size based on widget size
+  const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;
+  const radius = gaugeSize / 2 - 8;
+  const circumference = radius * 2 * Math.PI;
+
   return (
     <div className="flex items-center gap-6">
-      <div className="relative w-28 h-28">
+      <div className="relative flex-shrink-0" style={{ width: gaugeSize, height: gaugeSize }}>
         <svg className="w-full h-full -rotate-90">
-          <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
           <circle
-            cx="56"
-            cy="56"
-            r="48"
+            cx={gaugeSize / 2}
+            cy={gaugeSize / 2}
+            r={radius}
+            fill="none"
+            stroke="#3d3d3d"
+            strokeWidth="10"
+          />
+          <circle
+            cx={gaugeSize / 2}
+            cy={gaugeSize / 2}
+            r={radius}
             fill="none"
             stroke="#00ae42"
             strokeWidth="10"
             strokeLinecap="round"
-            strokeDasharray={`${successRate * 3.02} 302`}
+            strokeDasharray={`${(successRate / 100) * circumference} ${circumference}`}
           />
         </svg>
         <div className="absolute inset-0 flex items-center justify-center">
-          <span className="text-xl font-bold text-white">{successRate}%</span>
+          <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{successRate}%</span>
         </div>
       </div>
-      <div className="space-y-2">
-        <div className="flex items-center gap-2">
-          <CheckCircle className="w-4 h-4 text-bambu-green" />
-          <span className="text-sm text-bambu-gray">Successful:</span>
-          <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
-        </div>
-        <div className="flex items-center gap-2">
-          <XCircle className="w-4 h-4 text-red-400" />
-          <span className="text-sm text-bambu-gray">Failed:</span>
-          <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
+      <div className="flex-1 min-w-0">
+        <div className="space-y-2">
+          <div className="flex items-center gap-2">
+            <CheckCircle className="w-4 h-4 text-bambu-green flex-shrink-0" />
+            <span className="text-sm text-bambu-gray">Successful:</span>
+            <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
+          </div>
+          <div className="flex items-center gap-2">
+            <XCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
+            <span className="text-sm text-bambu-gray">Failed:</span>
+            <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
+          </div>
         </div>
+        {/* Show per-printer breakdown when expanded */}
+        {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (
+          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
+            <p className="text-xs text-bambu-gray font-medium mb-2">Prints by Printer</p>
+            <div className={`grid gap-x-6 gap-y-1 ${size === 4 ? 'grid-cols-3' : 'grid-cols-2'}`} style={{ width: 'fit-content' }}>
+              {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
+                <div key={printerId} className="flex items-center gap-3 text-sm">
+                  <span className="text-bambu-gray truncate max-w-[120px]">
+                    {printerMap.get(printerId) || `Printer ${printerId}`}
+                  </span>
+                  <span className="text-white font-medium">{count}</span>
+                </div>
+              ))}
+            </div>
+          </div>
+        )}
       </div>
     </div>
   );
@@ -153,12 +188,14 @@ function SuccessRateWidget({
 function TimeAccuracyWidget({
   stats,
   printerMap,
+  size = 1,
 }: {
   stats: {
     average_time_accuracy: number | null;
     time_accuracy_by_printer: Record<string, number> | null;
   } | undefined;
   printerMap: Map<string, string>;
+  size?: 1 | 2 | 4;
 }) {
   const accuracy = stats?.average_time_accuracy;
 
@@ -184,38 +221,56 @@ function TimeAccuracyWidget({
   const color = getColor(accuracy);
   const deviation = accuracy - 100;
 
+  // Scale gauge size based on widget size
+  const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;
+  const radius = gaugeSize / 2 - 8;
+  const circumference = radius * 2 * Math.PI;
+
+  // Show more printers when expanded
+  const maxPrinters = size === 1 ? 3 : size === 2 ? 6 : 999;
+  const printerEntries = stats?.time_accuracy_by_printer
+    ? Object.entries(stats.time_accuracy_by_printer).slice(0, maxPrinters)
+    : [];
+
   return (
     <div className="flex items-center gap-6">
-      <div className="relative w-28 h-28">
+      <div className="relative flex-shrink-0" style={{ width: gaugeSize, height: gaugeSize }}>
         <svg className="w-full h-full -rotate-90">
-          <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
           <circle
-            cx="56"
-            cy="56"
-            r="48"
+            cx={gaugeSize / 2}
+            cy={gaugeSize / 2}
+            r={radius}
+            fill="none"
+            stroke="#3d3d3d"
+            strokeWidth="10"
+          />
+          <circle
+            cx={gaugeSize / 2}
+            cy={gaugeSize / 2}
+            r={radius}
             fill="none"
             stroke={color}
             strokeWidth="10"
             strokeLinecap="round"
-            strokeDasharray={`${normalizedForGauge * 3.02} 302`}
+            strokeDasharray={`${(normalizedForGauge / 100) * circumference} ${circumference}`}
           />
         </svg>
         <div className="absolute inset-0 flex flex-col items-center justify-center">
-          <span className="text-xl font-bold text-white">{accuracy.toFixed(0)}%</span>
+          <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{accuracy.toFixed(0)}%</span>
           <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
             {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
           </span>
         </div>
       </div>
-      <div className="space-y-2 flex-1">
+      <div className="flex-1 min-w-0">
         <div className="flex items-center gap-2 text-xs text-bambu-gray">
-          <Target className="w-3 h-3" />
+          <Target className="w-3 h-3 flex-shrink-0" />
           <span>100% = perfect estimate</span>
         </div>
-        {stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && (
-          <div className="space-y-1 mt-2">
-            {Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => (
-              <div key={printerId} className="flex items-center justify-between text-xs">
+        {printerEntries.length > 0 && (
+          <div className={`mt-2 ${size === 4 ? 'grid grid-cols-3 gap-x-6 gap-y-1' : size === 2 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`} style={{ width: 'fit-content' }}>
+            {printerEntries.map(([printerId, acc]) => (
+              <div key={printerId} className="flex items-center gap-2 text-xs">
                 <span className="text-bambu-gray truncate max-w-[100px]">
                   {printerMap.get(printerId) || `Printer ${printerId}`}
                 </span>
@@ -236,11 +291,13 @@ function TimeAccuracyWidget({
 
 function FilamentTypesWidget({
   stats,
+  size = 1,
 }: {
   stats: {
     total_prints: number;
     prints_by_filament_type: Record<string, number>;
   } | undefined;
+  size?: 1 | 2 | 4;
 }) {
   if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
     return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
@@ -251,9 +308,39 @@ function FilamentTypesWidget({
     ([, a], [, b]) => b - a
   );
 
+  // Limit entries based on size
+  const maxEntries = size === 1 ? 5 : size === 2 ? 8 : 999;
+  const displayEntries = sortedEntries.slice(0, maxEntries);
+  const hasMore = sortedEntries.length > maxEntries;
+
+  // Use grid layout when expanded
+  if (size === 4 && displayEntries.length > 4) {
+    return (
+      <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
+        {displayEntries.map(([type, count]) => {
+          const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
+          return (
+            <div key={type}>
+              <div className="flex justify-between text-sm mb-1">
+                <span className="text-white truncate max-w-[120px]">{type}</span>
+                <span className="text-bambu-gray">{count}</span>
+              </div>
+              <div className="h-2 bg-bambu-dark rounded-full">
+                <div
+                  className="h-full bg-bambu-green rounded-full transition-all"
+                  style={{ width: `${percentage}%` }}
+                />
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    );
+  }
+
   return (
     <div className="space-y-3">
-      {sortedEntries.map(([type, count]) => {
+      {displayEntries.map(([type, count]) => {
         const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
         return (
           <div key={type}>
@@ -270,12 +357,25 @@ function FilamentTypesWidget({
           </div>
         );
       })}
+      {hasMore && (
+        <p className="text-xs text-bambu-gray text-center pt-1">
+          +{sortedEntries.length - maxEntries} more types
+        </p>
+      )}
     </div>
   );
 }
 
-function PrintActivityWidget({ printDates }: { printDates: string[] }) {
-  return <PrintCalendar printDates={printDates} months={4} />;
+function PrintActivityWidget({
+  printDates,
+  size = 2,
+}: {
+  printDates: string[];
+  size?: 1 | 2 | 4;
+}) {
+  // Show more months when widget is larger - cell size auto-calculated
+  const months = size === 1 ? 3 : size === 2 ? 6 : 12;
+  return <PrintCalendar printDates={printDates} months={months} />;
 }
 
 function PrintsByPrinterWidget({
@@ -321,7 +421,7 @@ function FilamentTrendsWidget({
   return <FilamentTrends archives={archives} currency={currency} />;
 }
 
-function FailureAnalysisWidget() {
+function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
   const { data: analysis, isLoading } = useQuery({
     queryKey: ['failureAnalysis'],
     queryFn: () => api.getFailureAnalysis({ days: 30 }),
@@ -339,50 +439,63 @@ function FailureAnalysisWidget() {
     return <p className="text-bambu-gray text-center py-4">No print data in the last 30 days</p>;
   }
 
-  const topReasons = Object.entries(analysis.failures_by_reason)
-    .sort(([, a], [, b]) => b - a)
-    .slice(0, 5);
+  // Show more reasons when expanded
+  const maxReasons = size === 1 ? 5 : size === 2 ? 8 : 999;
+  const allReasons = Object.entries(analysis.failures_by_reason).sort(([, a], [, b]) => b - a);
+  const topReasons = allReasons.slice(0, maxReasons);
+  const hasMore = allReasons.length > maxReasons;
 
   return (
-    <div className="space-y-4">
+    <div className={`${size >= 2 ? 'flex gap-8' : 'space-y-4'}`}>
       {/* Summary */}
-      <div className="flex items-center gap-4">
-        <div className="flex items-center gap-2">
-          <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-red-400' : analysis.failure_rate > 10 ? 'text-yellow-400' : 'text-bambu-green'}`} />
-          <span className="text-2xl font-bold text-white">{analysis.failure_rate.toFixed(1)}%</span>
-          <span className="text-sm text-bambu-gray">failure rate</span>
+      <div className={size >= 2 ? 'flex-shrink-0' : ''}>
+        <div className="flex items-center gap-4">
+          <div className="flex items-center gap-2">
+            <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-red-400' : analysis.failure_rate > 10 ? 'text-yellow-400' : 'text-bambu-green'}`} />
+            <span className={`font-bold text-white ${size >= 2 ? 'text-3xl' : 'text-2xl'}`}>{analysis.failure_rate.toFixed(1)}%</span>
+          </div>
         </div>
-        <div className="text-sm text-bambu-gray">
+        <div className="text-sm text-bambu-gray mt-1">
           {analysis.failed_prints} / {analysis.total_prints} prints failed
         </div>
+        {/* Trend indicator */}
+        {analysis.trend && analysis.trend.length >= 2 && (
+          <div className={`${size >= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}>
+            <div className="flex items-center gap-2 text-sm">
+              <TrendingDown className={`w-4 h-4 ${
+                analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate
+                  ? 'text-bambu-green'
+                  : 'text-red-400'
+              }`} />
+              <span className="text-bambu-gray">
+                Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
+              </span>
+            </div>
+          </div>
+        )}
       </div>
 
-      {/* Top Failure Reasons */}
+      {/* Failure Reasons */}
       {topReasons.length > 0 && (
-        <div className="space-y-2">
-          <p className="text-xs text-bambu-gray font-medium">Top Failure Reasons</p>
-          {topReasons.map(([reason, count]) => (
-            <div key={reason} className="flex items-center justify-between text-sm">
-              <span className="text-white truncate max-w-[200px]">{reason || 'Unknown'}</span>
-              <span className="text-bambu-gray">{count}</span>
-            </div>
-          ))}
-        </div>
-      )}
-
-      {/* Trend indicator */}
-      {analysis.trend && analysis.trend.length >= 2 && (
-        <div className="pt-2 border-t border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2 text-sm">
-            <TrendingDown className={`w-4 h-4 ${
-              analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate
-                ? 'text-bambu-green'
-                : 'text-red-400'
-            }`} />
-            <span className="text-bambu-gray">
-              Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
-            </span>
+        <div className={`flex-1 ${size >= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>
+          <p className="text-xs text-bambu-gray font-medium mb-2">
+            {size >= 2 ? 'Failure Reasons' : 'Top Failure Reasons'}
+          </p>
+          <div className={`${size === 4 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`}>
+            {topReasons.map(([reason, count]) => (
+              <div key={reason} className="flex items-center justify-between text-sm">
+                <span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>
+                  {reason || 'Unknown'}
+                </span>
+                <span className="text-bambu-gray ml-2">{count}</span>
+              </div>
+            ))}
           </div>
+          {hasMore && (
+            <p className="text-xs text-bambu-gray mt-2">
+              +{allReasons.length - maxReasons} more reasons
+            </p>
+          )}
         </div>
       )}
     </div>
@@ -473,6 +586,7 @@ export function StatsPage() {
 
   // Define dashboard widgets
   // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width
+  // Widgets can use render functions to receive the current size for responsive content
   const widgets: DashboardWidget[] = [
     {
       id: 'quick-stats',
@@ -483,31 +597,31 @@ export function StatsPage() {
     {
       id: 'success-rate',
       title: 'Success Rate',
-      component: <SuccessRateWidget stats={stats} />,
+      component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,
       defaultSize: 1,
     },
     {
       id: 'time-accuracy',
       title: 'Time Accuracy',
-      component: <TimeAccuracyWidget stats={stats} printerMap={printerMap} />,
+      component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,
       defaultSize: 1,
     },
     {
       id: 'filament-types',
       title: 'Filament Types',
-      component: <FilamentTypesWidget stats={stats} />,
+      component: (size) => <FilamentTypesWidget stats={stats} size={size} />,
       defaultSize: 1,
     },
     {
       id: 'failure-analysis',
       title: 'Failure Analysis (30 days)',
-      component: <FailureAnalysisWidget />,
+      component: (size) => <FailureAnalysisWidget size={size} />,
       defaultSize: 1,
     },
     {
       id: 'print-activity',
       title: 'Print Activity',
-      component: <PrintActivityWidget printDates={printDates} />,
+      component: (size) => <PrintActivityWidget printDates={printDates} size={size} />,
       defaultSize: 2,
     },
     {

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-48h_gFgJ.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-BEtulymk.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-CMSE_lmd.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DNaTWne2.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BEtulymk.css">
+    <script type="module" crossorigin src="/assets/index-CMSE_lmd.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-48h_gFgJ.css">
   </head>
   <body>
     <div id="root"></div>

+ 2 - 2
static/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v4';
-const STATIC_CACHE = 'bambuddy-static-v4';
+const CACHE_NAME = 'bambuddy-v15';
+const STATIC_CACHE = 'bambuddy-static-v15';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác