respira/src/components/ProgressMonitor.tsx
Jan-Henrik Bruhn f2d05c2714 fix: Fall back to PEN stitch count when machine reports 0 total stitches
When the machine reports 0 total stitches in patternInfo, fall back to
using the PEN data stitch count (penStitches.stitches.length) for UI
display. This ensures progress percentage and stitch counts display
correctly even when the machine hasn't fully initialized pattern info.

Updated ProgressMonitor to use derived totalStitches value that prefers
patternInfo.totalStitches but falls back to PEN data when needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 12:14:41 +01:00

458 lines
19 KiB
TypeScript

import { useRef, useEffect, useState, useMemo } from "react";
import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from '../stores/usePatternStore';
import {
CheckCircleIcon,
ArrowRightIcon,
CircleStackIcon,
PlayIcon,
CheckBadgeIcon,
ClockIcon,
PauseCircleIcon,
ExclamationCircleIcon,
ChartBarIcon,
ArrowPathIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine";
import {
canStartSewing,
canStartMaskTrace,
canResumeSewing,
getStateVisualInfo,
} from "../utils/machineStateHelpers";
export function ProgressMonitor() {
// Machine store
const {
machineStatus,
patternInfo,
sewingProgress,
isDeleting,
startMaskTrace,
startSewing,
resumeSewing,
} = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
patternInfo: state.patternInfo,
sewingProgress: state.sewingProgress,
isDeleting: state.isDeleting,
startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing,
resumeSewing: state.resumeSewing,
}))
);
// Pattern store
const pesData = usePatternStore((state) => state.pesData);
const currentBlockRef = useRef<HTMLDivElement>(null);
const colorBlocksScrollRef = useRef<HTMLDivElement>(null);
const [showGradient, setShowGradient] = useState(true);
// State indicators
const isMaskTraceComplete =
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
const stateVisual = getStateVisualInfo(machineStatus);
// Use PEN stitch count as fallback when machine reports 0 total stitches
const totalStitches = patternInfo
? (patternInfo.totalStitches === 0 && pesData?.penStitches
? pesData.penStitches.stitches.length
: patternInfo.totalStitches)
: 0;
const progressPercent = totalStitches > 0
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
: 0;
// Calculate color block information from decoded penStitches
const colorBlocks = useMemo(() => {
if (!pesData || !pesData.penStitches) return [];
const blocks: Array<{
colorIndex: number;
threadHex: string;
startStitch: number;
endStitch: number;
stitchCount: number;
threadCatalogNumber: string | null;
threadBrand: string | null;
threadDescription: string | null;
threadChart: string | null;
}> = [];
// Use the pre-computed color blocks from decoded PEN data
for (const penBlock of pesData.penStitches.colorBlocks) {
const thread = pesData.threads[penBlock.colorIndex];
blocks.push({
colorIndex: penBlock.colorIndex,
threadHex: thread?.hex || "#000000",
threadCatalogNumber: thread?.catalogNumber ?? null,
threadBrand: thread?.brand ?? null,
threadDescription: thread?.description ?? null,
threadChart: thread?.chart ?? null,
startStitch: penBlock.startStitchIndex,
endStitch: penBlock.endStitchIndex,
stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex,
});
}
return blocks;
}, [pesData]);
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;
const currentBlockIndex = colorBlocks.findIndex(
(block) =>
currentStitch >= block.startStitch && currentStitch < block.endStitch,
);
// Auto-scroll to current block
useEffect(() => {
if (currentBlockRef.current) {
currentBlockRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [currentBlockIndex]);
// Handle scroll to detect if at bottom
const handleColorBlocksScroll = () => {
if (colorBlocksScrollRef.current) {
const { scrollTop, scrollHeight, clientHeight } = colorBlocksScrollRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold
setShowGradient(!isAtBottom);
}
};
// Check initial scroll state and update on resize
useEffect(() => {
const checkScrollable = () => {
if (colorBlocksScrollRef.current) {
const { scrollHeight, clientHeight } = colorBlocksScrollRef.current;
const isScrollable = scrollHeight > clientHeight;
setShowGradient(isScrollable);
}
};
checkScrollable();
window.addEventListener('resize', checkScrollable);
return () => window.removeEventListener('resize', checkScrollable);
}, [colorBlocks]);
const stateIndicatorColors = {
idle: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
info: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
active: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
waiting: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
warning: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
complete: "bg-green-50 dark:bg-green-900/20 border-green-600",
success: "bg-green-50 dark:bg-green-900/20 border-green-600",
interrupted: "bg-red-50 dark:bg-red-900/20 border-red-600",
error: "bg-red-50 dark:bg-red-900/20 border-red-600",
danger: "bg-red-50 dark:bg-red-900/20 border-red-600",
};
return (
<div className="lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-purple-600 dark:border-purple-500 flex flex-col lg:overflow-hidden">
<div className="flex items-start gap-3 mb-3">
<ChartBarIcon className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Sewing Progress
</h3>
{sewingProgress && (
<p className="text-xs text-gray-600 dark:text-gray-400">
{progressPercent.toFixed(1)}% complete
</p>
)}
</div>
</div>
{/* Pattern Info */}
{patternInfo && (
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{totalStitches.toLocaleString()}
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Est. Time
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{Math.floor(patternInfo.totalTime / 60)}:
{String(patternInfo.totalTime % 60).padStart(2, "0")}
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Speed
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{patternInfo.speed} spm
</span>
</div>
</div>
)}
{/* Progress Bar */}
{sewingProgress && (
<div className="mb-3">
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
<div
className="h-full bg-gradient-to-r from-purple-600 to-purple-700 dark:from-purple-600 dark:to-purple-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Current Stitch
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{sewingProgress.currentStitch.toLocaleString()} /{" "}
{totalStitches.toLocaleString()}
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Time Elapsed
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{Math.floor(sewingProgress.currentTime / 60)}:
{String(sewingProgress.currentTime % 60).padStart(2, "0")}
</span>
</div>
</div>
</div>
)}
{/* State Visual Indicator */}
{patternInfo &&
(() => {
const iconMap = {
ready: (
<ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
),
active: (
<PlayIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
),
waiting: (
<PauseCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
),
complete: (
<CheckBadgeIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
),
interrupted: (
<PauseCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
),
error: (
<ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
),
};
return (
<div
className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}
>
<div className="flex-shrink-0">
{iconMap[stateVisual.iconName]}
</div>
<div className="flex-1">
<div className="font-semibold text-xs dark:text-gray-100">
{stateVisual.label}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{stateVisual.description}
</div>
</div>
</div>
);
})()}
{/* Color Blocks */}
{colorBlocks.length > 0 && (
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
Color Blocks
</h4>
<div className="relative lg:flex-1 lg:min-h-0">
<div
ref={colorBlocksScrollRef}
onScroll={handleColorBlocksScroll}
className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-blue-600 dark:[&::-webkit-scrollbar-thumb]:bg-blue-500 [&::-webkit-scrollbar-thumb]:rounded-full"
>
{colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch;
const isCurrent = index === currentBlockIndex;
// Calculate progress within current block
let blockProgress = 0;
if (isCurrent) {
blockProgress =
((currentStitch - block.startStitch) / block.stitchCount) *
100;
} else if (isCompleted) {
blockProgress = 100;
}
return (
<div
key={index}
ref={isCurrent ? currentBlockRef : null}
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted
? "border-green-600 bg-green-50 dark:bg-green-900/20"
: isCurrent
? "border-purple-600 bg-purple-50 dark:bg-purple-900/20 shadow-lg shadow-purple-600/20 animate-pulseGlow"
: "border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70"
}`}
role="listitem"
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
>
<div className="flex items-center gap-2.5">
{/* Color swatch */}
<div
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
style={{
backgroundColor: block.threadHex,
...(isCurrent && { borderColor: "#9333ea" }),
}}
title={`Thread color: ${block.threadHex}`}
aria-label={`Thread color ${block.threadHex}`}
/>
{/* Thread info */}
<div className="flex-1 min-w-0">
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
Thread {block.colorIndex + 1}
{(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && (
<span className="font-normal text-gray-600 dark:text-gray-400">
{" "}
(
{(() => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
block.threadBrand,
block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
].filter(Boolean).join(" ");
// Secondary metadata: chart and description
const secondaryMetadata = [
block.threadChart,
block.threadDescription
].filter(Boolean).join(" ");
return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
})()}
)
</span>
)}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{block.stitchCount.toLocaleString()} stitches
</div>
</div>
{/* Status icon */}
{isCompleted ? (
<CheckCircleIcon
className="w-5 h-5 text-green-600 flex-shrink-0"
aria-label="Completed"
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse"
aria-label="In progress"
/>
) : (
<CircleStackIcon
className="w-5 h-5 text-gray-400 flex-shrink-0"
aria-label="Pending"
/>
)}
</div>
{/* Progress bar for current block */}
{isCurrent && (
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full"
style={{ width: `${blockProgress}%` }}
role="progressbar"
aria-valuenow={Math.round(blockProgress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${Math.round(blockProgress)}% complete`}
/>
</div>
)}
</div>
);
})}
</div>
{/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */}
{showGradient && (
<div className="hidden lg:block absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white dark:from-gray-800 to-transparent pointer-events-none" />
)}
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2 flex-shrink-0">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<button
onClick={resumeSewing}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</button>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<button
onClick={startSewing}
disabled={isDeleting}
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<button
onClick={startMaskTrace}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={
isMaskTraceComplete
? "Start mask trace again"
: "Start mask trace"
}
>
<ArrowPathIcon className="w-3.5 h-3.5" />
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
</button>
)}
</div>
</div>
);
}