mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
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>
458 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|