mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
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
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
const dateStr = day.toISOString().split('T')[0];
|
||||
const count = printCounts[dateStr] || 0;
|
||||
const isToday = dateStr === new Date().toISOString().split('T')[0];
|
||||
|
||||
return (
|
||||
<div className="flex" style={{ gap }}>
|
||||
{/* Day labels */}
|
||||
<div className="flex flex-col" style={{ gap, marginRight: 4, width: dayLabelWidth }}>
|
||||
{dayLabels.map((day, i) => (
|
||||
<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' : ''}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* 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" />
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
<span>More</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user