mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
refactor: Extract ProgressMonitor sub-components and utilities
- Move ProgressMonitor into dedicated folder with sub-components - Extract ProgressStats, ProgressSection, ColorBlockList, ColorBlockItem, ProgressActions - Create threadMetadata utility for formatting thread metadata - Reduce ProgressMonitor.tsx from 389 to 178 lines (54% reduction) Part of #33: Extract sub-components from large components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2de8cd12ff
commit
dade72453e
10 changed files with 566 additions and 389 deletions
|
|
@ -1,388 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { useAutoScroll } from "@/hooks";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore } from "../stores/useMachineStore";
|
||||
import { usePatternStore } from "../stores/usePatternStore";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
CircleStackIcon,
|
||||
PlayIcon,
|
||||
ChartBarIcon,
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { MachineStatus } from "../types/machine";
|
||||
import {
|
||||
canStartSewing,
|
||||
canStartMaskTrace,
|
||||
canResumeSewing,
|
||||
} from "../utils/machineStateHelpers";
|
||||
import { calculatePatternTime } from "../utils/timeCalculation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
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 uploadedPesData = usePatternStore((state) => state.uploadedPesData);
|
||||
const displayPattern = uploadedPesData || pesData;
|
||||
|
||||
// State indicators
|
||||
const isMaskTraceComplete =
|
||||
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
|
||||
|
||||
// Use PEN stitch count as fallback when machine reports 0 total stitches
|
||||
const totalStitches = patternInfo
|
||||
? patternInfo.totalStitches === 0 && displayPattern?.penStitches
|
||||
? displayPattern.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 (!displayPattern || !displayPattern.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 displayPattern.penStitches.colorBlocks) {
|
||||
const thread = displayPattern.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;
|
||||
}, [displayPattern]);
|
||||
|
||||
// Determine current color block based on current stitch
|
||||
const currentStitch = sewingProgress?.currentStitch || 0;
|
||||
const currentBlockIndex = colorBlocks.findIndex(
|
||||
(block) =>
|
||||
currentStitch >= block.startStitch && currentStitch < block.endStitch,
|
||||
);
|
||||
|
||||
// Calculate time based on color blocks (matches Brother app calculation)
|
||||
const { totalMinutes, elapsedMinutes } = useMemo(() => {
|
||||
if (colorBlocks.length === 0) {
|
||||
return { totalMinutes: 0, elapsedMinutes: 0 };
|
||||
}
|
||||
const result = calculatePatternTime(colorBlocks, currentStitch);
|
||||
return {
|
||||
totalMinutes: result.totalMinutes,
|
||||
elapsedMinutes: result.elapsedMinutes,
|
||||
};
|
||||
}, [colorBlocks, currentStitch]);
|
||||
|
||||
// Auto-scroll to current block
|
||||
const currentBlockRef = useAutoScroll<HTMLDivElement>(currentBlockIndex);
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm">Sewing Progress</CardTitle>
|
||||
{sewingProgress && (
|
||||
<CardDescription className="text-xs">
|
||||
{progressPercent.toFixed(1)}% complete
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
|
||||
{/* Pattern Info */}
|
||||
{patternInfo && (
|
||||
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
|
||||
<div className="bg-gray-200 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-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Total Time
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{totalMinutes} min
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-200 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">
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||
<div className="bg-gray-200 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-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Time
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{elapsedMinutes} / {totalMinutes} min
|
||||
</span>
|
||||
</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>
|
||||
<ScrollArea className="lg:flex-1 lg:h-0">
|
||||
<div className="flex flex-col gap-2 pr-4">
|
||||
{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-success-600 bg-success-50 dark:bg-success-900/20"
|
||||
: isCurrent
|
||||
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
|
||||
: "border-gray-200 dark:border-gray-600 bg-gray-100 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,
|
||||
}}
|
||||
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
|
||||
// Only show chart if it's different from catalogNumber
|
||||
const secondaryMetadata = [
|
||||
block.threadChart &&
|
||||
block.threadChart !==
|
||||
block.threadCatalogNumber
|
||||
? block.threadChart
|
||||
: null,
|
||||
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-success-600 flex-shrink-0"
|
||||
aria-label="Completed"
|
||||
/>
|
||||
) : isCurrent ? (
|
||||
<ArrowRightIcon
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400 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 && (
|
||||
<Progress
|
||||
value={blockProgress}
|
||||
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
|
||||
aria-label={`${Math.round(blockProgress)}% complete`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</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"
|
||||
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]"
|
||||
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}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
126
src/components/ProgressMonitor/ColorBlockItem.tsx
Normal file
126
src/components/ProgressMonitor/ColorBlockItem.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* ColorBlockItem Component
|
||||
*
|
||||
* Renders an individual color block card with thread metadata, stitch count, status icon, and progress
|
||||
*/
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
CircleStackIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { formatThreadMetadata } from "../../utils/threadMetadata";
|
||||
|
||||
export interface ColorBlock {
|
||||
colorIndex: number;
|
||||
threadHex: string;
|
||||
startStitch: number;
|
||||
endStitch: number;
|
||||
stitchCount: number;
|
||||
threadCatalogNumber: string | null;
|
||||
threadBrand: string | null;
|
||||
threadDescription: string | null;
|
||||
threadChart: string | null;
|
||||
}
|
||||
|
||||
interface ColorBlockItemProps {
|
||||
block: ColorBlock;
|
||||
index: number;
|
||||
currentStitch: number;
|
||||
isCurrent: boolean;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
export const ColorBlockItem = forwardRef<HTMLDivElement, ColorBlockItemProps>(
|
||||
({ block, index, currentStitch, isCurrent, isCompleted }, ref) => {
|
||||
// Calculate progress within current block
|
||||
let blockProgress = 0;
|
||||
if (isCurrent) {
|
||||
blockProgress =
|
||||
((currentStitch - block.startStitch) / block.stitchCount) * 100;
|
||||
} else if (isCompleted) {
|
||||
blockProgress = 100;
|
||||
}
|
||||
|
||||
const hasMetadata =
|
||||
block.threadBrand ||
|
||||
block.threadChart ||
|
||||
block.threadDescription ||
|
||||
block.threadCatalogNumber;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={isCurrent ? ref : null}
|
||||
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
|
||||
isCompleted
|
||||
? "border-success-600 bg-success-50 dark:bg-success-900/20"
|
||||
: isCurrent
|
||||
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
|
||||
: "border-gray-200 dark:border-gray-600 bg-gray-100 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,
|
||||
}}
|
||||
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}
|
||||
{hasMetadata && (
|
||||
<span className="font-normal text-gray-600 dark:text-gray-400">
|
||||
{" "}
|
||||
({formatThreadMetadata(block)})
|
||||
</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-success-600 flex-shrink-0"
|
||||
aria-label="Completed"
|
||||
/>
|
||||
) : isCurrent ? (
|
||||
<ArrowRightIcon
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400 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 && (
|
||||
<Progress
|
||||
value={blockProgress}
|
||||
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
|
||||
aria-label={`${Math.round(blockProgress)}% complete`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ColorBlockItem.displayName = "ColorBlockItem";
|
||||
52
src/components/ProgressMonitor/ColorBlockList.tsx
Normal file
52
src/components/ProgressMonitor/ColorBlockList.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* ColorBlockList Component
|
||||
*
|
||||
* Container for the scrollable list of color blocks
|
||||
*/
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ColorBlockItem, type ColorBlock } from "./ColorBlockItem";
|
||||
|
||||
interface ColorBlockListProps {
|
||||
colorBlocks: ColorBlock[];
|
||||
currentStitch: number;
|
||||
currentBlockIndex: number;
|
||||
currentBlockRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function ColorBlockList({
|
||||
colorBlocks,
|
||||
currentStitch,
|
||||
currentBlockIndex,
|
||||
currentBlockRef,
|
||||
}: ColorBlockListProps) {
|
||||
if (colorBlocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<ScrollArea className="lg:flex-1 lg:h-0">
|
||||
<div className="flex flex-col gap-2 pr-4">
|
||||
{colorBlocks.map((block, index) => {
|
||||
const isCompleted = currentStitch >= block.endStitch;
|
||||
const isCurrent = index === currentBlockIndex;
|
||||
|
||||
return (
|
||||
<ColorBlockItem
|
||||
key={index}
|
||||
ref={isCurrent ? currentBlockRef : null}
|
||||
block={block}
|
||||
index={index}
|
||||
currentStitch={currentStitch}
|
||||
isCurrent={isCurrent}
|
||||
isCompleted={isCompleted}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/components/ProgressMonitor/ProgressActions.tsx
Normal file
78
src/components/ProgressMonitor/ProgressActions.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* ProgressActions Component
|
||||
*
|
||||
* Renders action buttons (Resume Sewing, Start Sewing, Start Mask Trace)
|
||||
*/
|
||||
|
||||
import { PlayIcon, ArrowPathIcon } from "@heroicons/react/24/solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MachineStatus } from "../../types/machine";
|
||||
import {
|
||||
canStartSewing,
|
||||
canStartMaskTrace,
|
||||
canResumeSewing,
|
||||
} from "../../utils/machineStateHelpers";
|
||||
|
||||
interface ProgressActionsProps {
|
||||
machineStatus: MachineStatus;
|
||||
isDeleting: boolean;
|
||||
isMaskTraceComplete: boolean;
|
||||
onResumeSewing: () => void;
|
||||
onStartSewing: () => void;
|
||||
onStartMaskTrace: () => void;
|
||||
}
|
||||
|
||||
export function ProgressActions({
|
||||
machineStatus,
|
||||
isDeleting,
|
||||
isMaskTraceComplete,
|
||||
onResumeSewing,
|
||||
onStartSewing,
|
||||
onStartMaskTrace,
|
||||
}: ProgressActionsProps) {
|
||||
return (
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
{/* Resume has highest priority when available */}
|
||||
{canResumeSewing(machineStatus) && (
|
||||
<Button
|
||||
onClick={onResumeSewing}
|
||||
disabled={isDeleting}
|
||||
className="flex-1"
|
||||
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={onStartSewing}
|
||||
disabled={isDeleting}
|
||||
className="flex-[2]"
|
||||
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={onStartMaskTrace}
|
||||
disabled={isDeleting}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
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>
|
||||
);
|
||||
}
|
||||
174
src/components/ProgressMonitor/ProgressMonitor.tsx
Normal file
174
src/components/ProgressMonitor/ProgressMonitor.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* ProgressMonitor Component
|
||||
*
|
||||
* Orchestrates progress monitoring UI with stats, progress bar, color blocks, and action buttons
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAutoScroll } from "@/hooks";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore } from "../../stores/useMachineStore";
|
||||
import { usePatternStore } from "../../stores/usePatternStore";
|
||||
import { ChartBarIcon } from "@heroicons/react/24/solid";
|
||||
import { MachineStatus } from "../../types/machine";
|
||||
import { calculatePatternTime } from "../../utils/timeCalculation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { ProgressStats } from "./ProgressStats";
|
||||
import { ProgressSection } from "./ProgressSection";
|
||||
import { ColorBlockList } from "./ColorBlockList";
|
||||
import { ProgressActions } from "./ProgressActions";
|
||||
import type { ColorBlock } from "./ColorBlockItem";
|
||||
|
||||
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 uploadedPesData = usePatternStore((state) => state.uploadedPesData);
|
||||
const displayPattern = uploadedPesData || pesData;
|
||||
|
||||
// State indicators
|
||||
const isMaskTraceComplete =
|
||||
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
|
||||
|
||||
// Use PEN stitch count as fallback when machine reports 0 total stitches
|
||||
const totalStitches = patternInfo
|
||||
? patternInfo.totalStitches === 0 && displayPattern?.penStitches
|
||||
? displayPattern.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 (!displayPattern || !displayPattern.penStitches) return [];
|
||||
|
||||
const blocks: ColorBlock[] = [];
|
||||
|
||||
// Use the pre-computed color blocks from decoded PEN data
|
||||
for (const penBlock of displayPattern.penStitches.colorBlocks) {
|
||||
const thread = displayPattern.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;
|
||||
}, [displayPattern]);
|
||||
|
||||
// Determine current color block based on current stitch
|
||||
const currentStitch = sewingProgress?.currentStitch || 0;
|
||||
const currentBlockIndex = colorBlocks.findIndex(
|
||||
(block) =>
|
||||
currentStitch >= block.startStitch && currentStitch < block.endStitch,
|
||||
);
|
||||
|
||||
// Calculate time based on color blocks (matches Brother app calculation)
|
||||
const { totalMinutes, elapsedMinutes } = useMemo(() => {
|
||||
if (colorBlocks.length === 0) {
|
||||
return { totalMinutes: 0, elapsedMinutes: 0 };
|
||||
}
|
||||
const result = calculatePatternTime(colorBlocks, currentStitch);
|
||||
return {
|
||||
totalMinutes: result.totalMinutes,
|
||||
elapsedMinutes: result.elapsedMinutes,
|
||||
};
|
||||
}, [colorBlocks, currentStitch]);
|
||||
|
||||
// Auto-scroll to current block
|
||||
const currentBlockRef = useAutoScroll<HTMLDivElement>(currentBlockIndex);
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm">Sewing Progress</CardTitle>
|
||||
{sewingProgress && (
|
||||
<CardDescription className="text-xs">
|
||||
{progressPercent.toFixed(1)}% complete
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
|
||||
{/* Pattern Info */}
|
||||
{patternInfo && (
|
||||
<ProgressStats
|
||||
totalStitches={totalStitches}
|
||||
totalMinutes={totalMinutes}
|
||||
speed={patternInfo.speed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{sewingProgress && (
|
||||
<ProgressSection
|
||||
currentStitch={sewingProgress.currentStitch}
|
||||
totalStitches={totalStitches}
|
||||
elapsedMinutes={elapsedMinutes}
|
||||
totalMinutes={totalMinutes}
|
||||
progressPercent={progressPercent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color Blocks */}
|
||||
<ColorBlockList
|
||||
colorBlocks={colorBlocks}
|
||||
currentStitch={currentStitch}
|
||||
currentBlockIndex={currentBlockIndex}
|
||||
currentBlockRef={currentBlockRef}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<ProgressActions
|
||||
machineStatus={machineStatus}
|
||||
isDeleting={isDeleting}
|
||||
isMaskTraceComplete={isMaskTraceComplete}
|
||||
onResumeSewing={resumeSewing}
|
||||
onStartSewing={startSewing}
|
||||
onStartMaskTrace={startMaskTrace}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
49
src/components/ProgressMonitor/ProgressSection.tsx
Normal file
49
src/components/ProgressMonitor/ProgressSection.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* ProgressSection Component
|
||||
*
|
||||
* Displays the progress bar and current/total stitch information
|
||||
*/
|
||||
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface ProgressSectionProps {
|
||||
currentStitch: number;
|
||||
totalStitches: number;
|
||||
elapsedMinutes: number;
|
||||
totalMinutes: number;
|
||||
progressPercent: number;
|
||||
}
|
||||
|
||||
export function ProgressSection({
|
||||
currentStitch,
|
||||
totalStitches,
|
||||
elapsedMinutes,
|
||||
totalMinutes,
|
||||
progressPercent,
|
||||
}: ProgressSectionProps) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||
<div className="bg-gray-200 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">
|
||||
{currentStitch.toLocaleString()} / {totalStitches.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">Time</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{elapsedMinutes} / {totalMinutes} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/ProgressMonitor/ProgressStats.tsx
Normal file
44
src/components/ProgressMonitor/ProgressStats.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* ProgressStats Component
|
||||
*
|
||||
* Displays three stat cards: total stitches, total time, and speed
|
||||
*/
|
||||
|
||||
interface ProgressStatsProps {
|
||||
totalStitches: number;
|
||||
totalMinutes: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export function ProgressStats({
|
||||
totalStitches,
|
||||
totalMinutes,
|
||||
speed,
|
||||
}: ProgressStatsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
|
||||
<div className="bg-gray-200 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-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Total Time
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{totalMinutes} min
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-200 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">
|
||||
{speed} spm
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/components/ProgressMonitor/index.ts
Normal file
5
src/components/ProgressMonitor/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* ProgressMonitor component barrel export
|
||||
*/
|
||||
|
||||
export { ProgressMonitor } from "./ProgressMonitor";
|
||||
|
|
@ -7,7 +7,10 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { useClickOutside } from "@/hooks";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore, usePatternUploaded } from "../../stores/useMachineStore";
|
||||
import {
|
||||
useMachineStore,
|
||||
usePatternUploaded,
|
||||
} from "../../stores/useMachineStore";
|
||||
import { usePatternStore } from "../../stores/usePatternStore";
|
||||
import { WORKFLOW_STEPS } from "../../constants/workflowSteps";
|
||||
import { getCurrentStep } from "../../utils/workflowStepCalculation";
|
||||
|
|
|
|||
34
src/utils/threadMetadata.ts
Normal file
34
src/utils/threadMetadata.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Format thread metadata for display
|
||||
* Combines brand, catalog number, chart, and description into a readable string
|
||||
*/
|
||||
|
||||
interface ThreadMetadata {
|
||||
threadBrand: string | null;
|
||||
threadCatalogNumber: string | null;
|
||||
threadChart: string | null;
|
||||
threadDescription: string | null;
|
||||
}
|
||||
|
||||
export function formatThreadMetadata(thread: ThreadMetadata): string {
|
||||
// Primary metadata: brand and catalog number
|
||||
const primaryMetadata = [
|
||||
thread.threadBrand,
|
||||
thread.threadCatalogNumber ? `#${thread.threadCatalogNumber}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// Secondary metadata: chart and description
|
||||
// Only show chart if it's different from catalogNumber
|
||||
const secondaryMetadata = [
|
||||
thread.threadChart && thread.threadChart !== thread.threadCatalogNumber
|
||||
? thread.threadChart
|
||||
: null,
|
||||
thread.threadDescription,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
|
||||
}
|
||||
Loading…
Reference in a new issue