From e015c587bdd30b4ad83ef6f9847a1bbe83356bc6 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Fri, 12 Dec 2025 21:28:52 +0100 Subject: [PATCH 1/3] feature: Implement Zustand state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add zustand dependency for modern state management - Create three separate stores for better code organization: - useMachineStore: Machine connection, status, and operations - usePatternStore: Pattern data, offset, and upload state - useUIStore: Pyodide and UI-specific state - Migrate App.tsx from useBrotherMachine hook to Zustand stores - Use useShallow for optimized multi-value selections - Implement dynamic polling intervals based on machine state - Add ESLint ignore for .vite build directory Benefits: - Better separation of concerns with logical store divisions - Improved performance through selector-based subscriptions - Cleaner code replacing 445-line hook with maintainable stores - Full TypeScript support with proper typing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- eslint.config.mjs | 2 +- package-lock.json | 31 +- package.json | 3 +- src/App.tsx | 283 ++++++++++------- src/stores/useMachineStore.ts | 554 ++++++++++++++++++++++++++++++++++ src/stores/usePatternStore.ts | 66 ++++ src/stores/useUIStore.ts | 51 ++++ 7 files changed, 877 insertions(+), 113 deletions(-) create mode 100644 src/stores/useMachineStore.ts create mode 100644 src/stores/usePatternStore.ts create mode 100644 src/stores/useUIStore.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 5e6b472..ff46326 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', '.vite']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/package-lock.json b/package-lock.json index e4e05e3..43c7c84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "react-dom": "^19.2.0", "react-konva": "^19.2.1", "tailwindcss": "^4.1.17", - "update-electron-app": "^3.1.2" + "update-electron-app": "^3.1.2", + "zustand": "^5.0.9" }, "devDependencies": { "@electron-forge/cli": "^7.10.2", @@ -15253,6 +15254,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 6a02677..676f159 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "react-dom": "^19.2.0", "react-konva": "^19.2.1", "tailwindcss": "^4.1.17", - "update-electron-app": "^3.1.2" + "update-electron-app": "^3.1.2", + "zustand": "^5.0.9" }, "devDependencies": { "@electron-forge/cli": "^7.10.2", diff --git a/src/App.tsx b/src/App.tsx index 55df094..7baa6d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,8 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useBrotherMachine } from './hooks/useBrotherMachine'; +import { useEffect, useCallback, useRef } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { useMachineStore } from './stores/useMachineStore'; +import { usePatternStore } from './stores/usePatternStore'; +import { useUIStore } from './stores/useUIStore'; import { FileUpload } from './components/FileUpload'; import { PatternCanvas } from './components/PatternCanvas'; import { ProgressMonitor } from './components/ProgressMonitor'; @@ -7,37 +10,112 @@ import { WorkflowStepper } from './components/WorkflowStepper'; import { PatternSummaryCard } from './components/PatternSummaryCard'; import { BluetoothDevicePicker } from './components/BluetoothDevicePicker'; import type { PesPatternData } from './utils/pystitchConverter'; -import { pyodideLoader } from './utils/pyodideLoader'; import { hasError, getErrorDetails } from './utils/errorCodeHelpers'; import { canDeletePattern, getStateVisualInfo } from './utils/machineStateHelpers'; import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid'; import './App.css'; function App() { - const machine = useBrotherMachine(); - const [pesData, setPesData] = useState(null); - const [pyodideReady, setPyodideReady] = useState(false); - const [pyodideError, setPyodideError] = useState(null); - const [patternOffset, setPatternOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); - const [patternUploaded, setPatternUploaded] = useState(false); - const [currentFileName, setCurrentFileName] = useState(''); // Track current pattern filename - const [showErrorPopover, setShowErrorPopover] = useState(false); + // Machine store + const { + isConnected, + machineInfo, + machineStatus, + machineStatusName, + machineError, + patternInfo, + sewingProgress, + uploadProgress, + error: machineErrorMessage, + isPairingError, + isCommunicating: isPolling, + isUploading, + isDeleting, + resumeAvailable, + resumeFileName, + resumedPattern, + connect, + disconnect, + uploadPattern, + startMaskTrace, + startSewing, + resumeSewing, + deletePattern, + } = useMachineStore( + useShallow((state) => ({ + isConnected: state.isConnected, + machineInfo: state.machineInfo, + machineStatus: state.machineStatus, + machineStatusName: state.machineStatusName, + machineError: state.machineError, + patternInfo: state.patternInfo, + sewingProgress: state.sewingProgress, + uploadProgress: state.uploadProgress, + error: state.error, + isPairingError: state.isPairingError, + isCommunicating: state.isCommunicating, + isUploading: state.isUploading, + isDeleting: state.isDeleting, + resumeAvailable: state.resumeAvailable, + resumeFileName: state.resumeFileName, + resumedPattern: state.resumedPattern, + connect: state.connect, + disconnect: state.disconnect, + uploadPattern: state.uploadPattern, + startMaskTrace: state.startMaskTrace, + startSewing: state.startSewing, + resumeSewing: state.resumeSewing, + deletePattern: state.deletePattern, + })) + ); + + // Pattern store + const { + pesData, + currentFileName, + patternOffset, + patternUploaded, + setPattern, + setPatternOffset, + setPatternUploaded, + clearPattern, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + currentFileName: state.currentFileName, + patternOffset: state.patternOffset, + patternUploaded: state.patternUploaded, + setPattern: state.setPattern, + setPatternOffset: state.setPatternOffset, + setPatternUploaded: state.setPatternUploaded, + clearPattern: state.clearPattern, + })) + ); + + // UI store + const { + pyodideReady, + pyodideError, + showErrorPopover, + initializePyodide, + setErrorPopover, + } = useUIStore( + useShallow((state) => ({ + pyodideReady: state.pyodideReady, + pyodideError: state.pyodideError, + showErrorPopover: state.showErrorPopover, + initializePyodide: state.initializePyodide, + setErrorPopover: state.setErrorPopover, + })) + ); + const errorPopoverRef = useRef(null); const errorButtonRef = useRef(null); // Initialize Pyodide on mount useEffect(() => { - pyodideLoader - .initialize() - .then(() => { - setPyodideReady(true); - console.log('[App] Pyodide initialized successfully'); - }) - .catch((err) => { - setPyodideError(err instanceof Error ? err.message : 'Failed to initialize Python environment'); - console.error('[App] Failed to initialize Pyodide:', err); - }); - }, []); + initializePyodide(); + }, [initializePyodide]); // Close error popover when clicking outside useEffect(() => { @@ -48,7 +126,7 @@ function App() { errorButtonRef.current && !errorButtonRef.current.contains(event.target as Node) ) { - setShowErrorPopover(false); + setErrorPopover(false); } }; @@ -56,54 +134,39 @@ function App() { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } - }, [showErrorPopover]); + }, [showErrorPopover, setErrorPopover]); // Auto-load cached pattern when available - const resumedPattern = machine.resumedPattern; - const resumeFileName = machine.resumeFileName; - if (resumedPattern && !pesData) { console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset); - setPesData(resumedPattern.pesData); + setPattern(resumedPattern.pesData, resumeFileName || ''); // Restore the cached pattern offset if (resumedPattern.patternOffset) { - setPatternOffset(resumedPattern.patternOffset); - } - // Preserve the filename from cache - if (resumeFileName) { - setCurrentFileName(resumeFileName); + setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y); } } const handlePatternLoaded = useCallback((data: PesPatternData, fileName: string) => { - setPesData(data); - setCurrentFileName(fileName); - // Reset pattern offset when new pattern is loaded - setPatternOffset({ x: 0, y: 0 }); - setPatternUploaded(false); - }, []); + setPattern(data, fileName); + }, [setPattern]); const handlePatternOffsetChange = useCallback((offsetX: number, offsetY: number) => { - setPatternOffset({ x: offsetX, y: offsetY }); - console.log('[App] Pattern offset changed:', { x: offsetX, y: offsetY }); - }, []); + setPatternOffset(offsetX, offsetY); + }, [setPatternOffset]); const handleUpload = useCallback(async (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => { - await machine.uploadPattern(penData, pesData, fileName, patternOffset); + await uploadPattern(penData, pesData, fileName, patternOffset); setPatternUploaded(true); - }, [machine]); + }, [uploadPattern, setPatternUploaded]); const handleDeletePattern = useCallback(async () => { - await machine.deletePattern(); - setPatternUploaded(false); - // NOTE: We intentionally DON'T clear setPesData(null) here + await deletePattern(); + clearPattern(); + // NOTE: We intentionally DON'T clear pesData in the pattern store // so the pattern remains visible in the canvas for re-editing and re-uploading - }, [machine]); + }, [deletePattern, clearPattern]); // Track pattern uploaded state based on machine status - const isConnected = machine.isConnected; - const patternInfo = machine.patternInfo; - if (!isConnected) { if (patternUploaded) { setPatternUploaded(false); @@ -117,7 +180,7 @@ function App() { } // Get state visual info for header status badge - const stateVisual = getStateVisualInfo(machine.machineStatus); + const stateVisual = getStateVisualInfo(machineStatus); const stateIcons = { ready: CheckCircleIcon, active: BoltIcon, @@ -134,40 +197,40 @@ function App() {
{/* Machine Connection Status - Responsive width column */}
-
-
+
+

Respira

- {machine.isConnected && machine.machineInfo?.serialNumber && ( + {isConnected && machineInfo?.serialNumber && ( - • {machine.machineInfo.serialNumber} + • {machineInfo.serialNumber} )} - {machine.isPolling && ( + {isPolling && ( )}
- {machine.isConnected ? ( + {isConnected ? ( <> {/* Error popover */} - {showErrorPopover && (machine.error || pyodideError) && ( + {showErrorPopover && (machineErrorMessage || pyodideError) && (
{(() => { - const errorDetails = getErrorDetails(machine.machineError); - const isPairingError = machine.isPairingError; - const errorMsg = pyodideError || machine.error || ''; - const isInfo = isPairingError || errorDetails?.isInformational; + const errorDetails = getErrorDetails(machineError); + const isPairingErr = isPairingError; + const errorMsg = pyodideError || machineErrorMessage || ''; + const isInfo = isPairingErr || errorDetails?.isInformational; const bgColor = isInfo ? 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500' @@ -261,7 +324,7 @@ function App() { : 'text-red-700 dark:text-red-300'; const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon; - const title = errorDetails?.title || (isPairingError ? 'Pairing Required' : 'Error'); + const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error'); return (
@@ -286,9 +349,9 @@ function App() { )} - {machine.machineError !== undefined && !errorDetails?.isInformational && ( + {machineError !== undefined && !errorDetails?.isInformational && (

- Error Code: 0x{machine.machineError.toString(16).toUpperCase().padStart(2, '0')} + Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}

)}
@@ -306,13 +369,13 @@ function App() { {/* Workflow Stepper - Flexible width column */}
@@ -323,7 +386,7 @@ function App() { {/* Left Column - Controls */}
{/* Connect Button - Show when disconnected */} - {!machine.isConnected && ( + {!isConnected && (
@@ -337,7 +400,7 @@ function App() {
diff --git a/src/components/WorkflowStepper.tsx b/src/components/WorkflowStepper.tsx index f55df37..b171d4e 100644 --- a/src/components/WorkflowStepper.tsx +++ b/src/components/WorkflowStepper.tsx @@ -1,17 +1,10 @@ import { useState, useRef, useEffect } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { useMachineStore } from '../stores/useMachineStore'; +import { usePatternStore } from '../stores/usePatternStore'; import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid'; import { MachineStatus } from '../types/machine'; -import { getErrorDetails } from '../utils/errorCodeHelpers'; - -interface WorkflowStepperProps { - machineStatus: MachineStatus; - isConnected: boolean; - hasPattern: boolean; - patternUploaded: boolean; - hasError?: boolean; - errorMessage?: string; - errorCode?: number; -} +import { getErrorDetails, hasError } from '../utils/errorCodeHelpers'; interface Step { id: number; @@ -256,15 +249,35 @@ function getCurrentStep(machineStatus: MachineStatus, isConnected: boolean, hasP } } -export function WorkflowStepper({ - machineStatus, - isConnected, - hasPattern, - patternUploaded, - hasError = false, - errorMessage, - errorCode -}: WorkflowStepperProps) { +export function WorkflowStepper() { + // Machine store + const { + machineStatus, + isConnected, + machineError, + error: errorMessage, + } = useMachineStore( + useShallow((state) => ({ + machineStatus: state.machineStatus, + isConnected: state.isConnected, + machineError: state.machineError, + error: state.error, + })) + ); + + // Pattern store + const { + pesData, + patternUploaded, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + patternUploaded: state.patternUploaded, + })) + ); + + const hasPattern = pesData !== null; + const hasErrorFlag = hasError(machineError); const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded); const [showPopover, setShowPopover] = useState(false); const [popoverStep, setPopoverStep] = useState(null); @@ -383,7 +396,7 @@ export function WorkflowStepper({ aria-label="Step guidance" > {(() => { - const content = getGuideContent(popoverStep, machineStatus, hasError, errorCode, errorMessage); + const content = getGuideContent(popoverStep, machineStatus, hasErrorFlag, machineError, errorMessage || undefined); if (!content) return null; const colorClasses = {