From dade72453e7e3636429943871f621e3735766cd7 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 27 Dec 2025 16:22:12 +0100 Subject: [PATCH] refactor: Extract ProgressMonitor sub-components and utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/ProgressMonitor.tsx | 388 ------------------ .../ProgressMonitor/ColorBlockItem.tsx | 126 ++++++ .../ProgressMonitor/ColorBlockList.tsx | 52 +++ .../ProgressMonitor/ProgressActions.tsx | 78 ++++ .../ProgressMonitor/ProgressMonitor.tsx | 174 ++++++++ .../ProgressMonitor/ProgressSection.tsx | 49 +++ .../ProgressMonitor/ProgressStats.tsx | 44 ++ src/components/ProgressMonitor/index.ts | 5 + .../WorkflowStepper/WorkflowStepper.tsx | 5 +- src/utils/threadMetadata.ts | 34 ++ 10 files changed, 566 insertions(+), 389 deletions(-) delete mode 100644 src/components/ProgressMonitor.tsx create mode 100644 src/components/ProgressMonitor/ColorBlockItem.tsx create mode 100644 src/components/ProgressMonitor/ColorBlockList.tsx create mode 100644 src/components/ProgressMonitor/ProgressActions.tsx create mode 100644 src/components/ProgressMonitor/ProgressMonitor.tsx create mode 100644 src/components/ProgressMonitor/ProgressSection.tsx create mode 100644 src/components/ProgressMonitor/ProgressStats.tsx create mode 100644 src/components/ProgressMonitor/index.ts create mode 100644 src/utils/threadMetadata.ts diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx deleted file mode 100644 index 66f6ef3..0000000 --- a/src/components/ProgressMonitor.tsx +++ /dev/null @@ -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(currentBlockIndex); - - return ( - - -
- -
- Sewing Progress - {sewingProgress && ( - - {progressPercent.toFixed(1)}% complete - - )} -
-
-
- - {/* Pattern Info */} - {patternInfo && ( -
-
- - Total Stitches - - - {totalStitches.toLocaleString()} - -
-
- - Total Time - - - {totalMinutes} min - -
-
- - Speed - - - {patternInfo.speed} spm - -
-
- )} - - {/* Progress Bar */} - {sewingProgress && ( -
- - -
-
- - Current Stitch - - - {sewingProgress.currentStitch.toLocaleString()} /{" "} - {totalStitches.toLocaleString()} - -
-
- - Time - - - {elapsedMinutes} / {totalMinutes} min - -
-
-
- )} - - {/* Color Blocks */} - {colorBlocks.length > 0 && ( -
-

- Color Blocks -

- -
- {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 ( -
-
- {/* Color swatch */} -
- - {/* Thread info */} -
-
- Thread {block.colorIndex + 1} - {(block.threadBrand || - block.threadChart || - block.threadDescription || - block.threadCatalogNumber) && ( - - {" "} - ( - {(() => { - // 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(" • "); - })()} - ) - - )} -
-
- {block.stitchCount.toLocaleString()} stitches -
-
- - {/* Status icon */} - {isCompleted ? ( - - ) : isCurrent ? ( - - ) : ( - - )} -
- - {/* Progress bar for current block */} - {isCurrent && ( - - )} -
- ); - })} -
- -
- )} - - {/* Action buttons */} -
- {/* Resume has highest priority when available */} - {canResumeSewing(machineStatus) && ( - - )} - - {/* Start Sewing - primary action, takes more space */} - {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && ( - - )} - - {/* Start Mask Trace - secondary action */} - {canStartMaskTrace(machineStatus) && ( - - )} -
- - - ); -} diff --git a/src/components/ProgressMonitor/ColorBlockItem.tsx b/src/components/ProgressMonitor/ColorBlockItem.tsx new file mode 100644 index 0000000..fb21fcd --- /dev/null +++ b/src/components/ProgressMonitor/ColorBlockItem.tsx @@ -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( + ({ 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 ( +
+
+ {/* Color swatch */} +
+ + {/* Thread info */} +
+
+ Thread {block.colorIndex + 1} + {hasMetadata && ( + + {" "} + ({formatThreadMetadata(block)}) + + )} +
+
+ {block.stitchCount.toLocaleString()} stitches +
+
+ + {/* Status icon */} + {isCompleted ? ( + + ) : isCurrent ? ( + + ) : ( + + )} +
+ + {/* Progress bar for current block */} + {isCurrent && ( + + )} +
+ ); + }, +); + +ColorBlockItem.displayName = "ColorBlockItem"; diff --git a/src/components/ProgressMonitor/ColorBlockList.tsx b/src/components/ProgressMonitor/ColorBlockList.tsx new file mode 100644 index 0000000..0ae0df2 --- /dev/null +++ b/src/components/ProgressMonitor/ColorBlockList.tsx @@ -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; +} + +export function ColorBlockList({ + colorBlocks, + currentStitch, + currentBlockIndex, + currentBlockRef, +}: ColorBlockListProps) { + if (colorBlocks.length === 0) return null; + + return ( +
+

+ Color Blocks +

+ +
+ {colorBlocks.map((block, index) => { + const isCompleted = currentStitch >= block.endStitch; + const isCurrent = index === currentBlockIndex; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/ProgressMonitor/ProgressActions.tsx b/src/components/ProgressMonitor/ProgressActions.tsx new file mode 100644 index 0000000..401a9f2 --- /dev/null +++ b/src/components/ProgressMonitor/ProgressActions.tsx @@ -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 ( +
+ {/* Resume has highest priority when available */} + {canResumeSewing(machineStatus) && ( + + )} + + {/* Start Sewing - primary action, takes more space */} + {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && ( + + )} + + {/* Start Mask Trace - secondary action */} + {canStartMaskTrace(machineStatus) && ( + + )} +
+ ); +} diff --git a/src/components/ProgressMonitor/ProgressMonitor.tsx b/src/components/ProgressMonitor/ProgressMonitor.tsx new file mode 100644 index 0000000..4947035 --- /dev/null +++ b/src/components/ProgressMonitor/ProgressMonitor.tsx @@ -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(currentBlockIndex); + + return ( + + +
+ +
+ Sewing Progress + {sewingProgress && ( + + {progressPercent.toFixed(1)}% complete + + )} +
+
+
+ + {/* Pattern Info */} + {patternInfo && ( + + )} + + {/* Progress Bar */} + {sewingProgress && ( + + )} + + {/* Color Blocks */} + + + {/* Action buttons */} + + +
+ ); +} diff --git a/src/components/ProgressMonitor/ProgressSection.tsx b/src/components/ProgressMonitor/ProgressSection.tsx new file mode 100644 index 0000000..4370e13 --- /dev/null +++ b/src/components/ProgressMonitor/ProgressSection.tsx @@ -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 ( +
+ + +
+
+ + Current Stitch + + + {currentStitch.toLocaleString()} / {totalStitches.toLocaleString()} + +
+
+ Time + + {elapsedMinutes} / {totalMinutes} min + +
+
+
+ ); +} diff --git a/src/components/ProgressMonitor/ProgressStats.tsx b/src/components/ProgressMonitor/ProgressStats.tsx new file mode 100644 index 0000000..d65945c --- /dev/null +++ b/src/components/ProgressMonitor/ProgressStats.tsx @@ -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 ( +
+
+ + Total Stitches + + + {totalStitches.toLocaleString()} + +
+
+ + Total Time + + + {totalMinutes} min + +
+
+ Speed + + {speed} spm + +
+
+ ); +} diff --git a/src/components/ProgressMonitor/index.ts b/src/components/ProgressMonitor/index.ts new file mode 100644 index 0000000..6c59a59 --- /dev/null +++ b/src/components/ProgressMonitor/index.ts @@ -0,0 +1,5 @@ +/** + * ProgressMonitor component barrel export + */ + +export { ProgressMonitor } from "./ProgressMonitor"; diff --git a/src/components/WorkflowStepper/WorkflowStepper.tsx b/src/components/WorkflowStepper/WorkflowStepper.tsx index c065ced..b2aebf8 100644 --- a/src/components/WorkflowStepper/WorkflowStepper.tsx +++ b/src/components/WorkflowStepper/WorkflowStepper.tsx @@ -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"; diff --git a/src/utils/threadMetadata.ts b/src/utils/threadMetadata.ts new file mode 100644 index 0000000..5433b10 --- /dev/null +++ b/src/utils/threadMetadata.ts @@ -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(" • "); +}