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:
Jan-Henrik Bruhn 2025-12-27 16:22:12 +01:00
parent 2de8cd12ff
commit dade72453e
10 changed files with 566 additions and 389 deletions

View file

@ -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>
);
}

View 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";

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
/**
* ProgressMonitor component barrel export
*/
export { ProgressMonitor } from "./ProgressMonitor";

View file

@ -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";

View 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(" • ");
}