From dabfa3b35a00b871daa320822f81ca310abac440 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Thu, 25 Dec 2025 16:48:47 +0100 Subject: [PATCH] feature: Add error badge with auto-opening popover for machine errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace error button with red error badge in AppHeader - Add auto-open popover when any error occurs (machine, pairing, or Pyodide) - Popover auto-closes when errors are cleared - Respect user dismissals (won't reopen for same error) - Remove error display from WorkflowStepper (single source of truth) - Add shortName field to error definitions (max 15 chars) - Add unit tests to validate error shortName length constraints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/components/AppHeader.tsx | 93 ++++++++++++++++++++++++++---- src/components/WorkflowStepper.tsx | 52 +---------------- src/utils/errorCodeHelpers.test.ts | 84 +++++++++++++++++++++++++++ src/utils/errorCodeHelpers.ts | 30 ++++++++++ 4 files changed, 200 insertions(+), 59 deletions(-) create mode 100644 src/utils/errorCodeHelpers.test.ts diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 516b6ed..0ec0976 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect, useRef } from "react"; import { useShallow } from "zustand/react/shallow"; import { useMachineStore } from "../stores/useMachineStore"; import { useUIStore } from "../stores/useUIStore"; @@ -7,6 +8,7 @@ import { getStateVisualInfo, getStatusIndicatorState, } from "../utils/machineStateHelpers"; +import { hasError, getErrorDetails } from "../utils/errorCodeHelpers"; import { CheckCircleIcon, BoltIcon, @@ -58,6 +60,15 @@ export function AppHeader() { })), ); + // State management for error popover auto-open/close + const [errorPopoverOpen, setErrorPopoverOpen] = useState(false); + const [dismissedErrorCode, setDismissedErrorCode] = useState( + null, + ); + const prevMachineErrorRef = useRef(undefined); + const prevErrorMessageRef = useRef(null); + const prevPyodideErrorRef = useRef(null); + // Get state visual info for header status badge const stateVisual = getStateVisualInfo(machineStatus); const stateIcons = { @@ -75,6 +86,66 @@ export function AppHeader() { ? getStatusIndicatorState(machineStatus) : "idle"; + // Auto-open/close error popover based on error state changes + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + const currentError = machineError; + const prevError = prevMachineErrorRef.current; + const currentErrorMessage = machineErrorMessage; + const prevErrorMessage = prevErrorMessageRef.current; + const currentPyodideError = pyodideError; + const prevPyodideError = prevPyodideErrorRef.current; + + // Check if there's any error now + const hasAnyError = + machineErrorMessage || pyodideError || hasError(currentError); + // Check if there was any error before + const hadAnyError = + prevErrorMessage || prevPyodideError || hasError(prevError); + + // Auto-open popover when new error appears + const isNewMachineError = + hasError(currentError) && + currentError !== prevError && + currentError !== dismissedErrorCode; + const isNewErrorMessage = + currentErrorMessage && currentErrorMessage !== prevErrorMessage; + const isNewPyodideError = + currentPyodideError && currentPyodideError !== prevPyodideError; + + if (isNewMachineError || isNewErrorMessage || isNewPyodideError) { + setErrorPopoverOpen(true); + } + + // Auto-close popover when all errors are cleared + if (!hasAnyError && hadAnyError) { + setErrorPopoverOpen(false); + setDismissedErrorCode(null); // Reset dismissed tracking + } + + // Update refs for next comparison + prevMachineErrorRef.current = currentError; + prevErrorMessageRef.current = currentErrorMessage; + prevPyodideErrorRef.current = currentPyodideError; + }, [machineError, machineErrorMessage, pyodideError, dismissedErrorCode]); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Handle manual popover dismiss + const handlePopoverOpenChange = (open: boolean) => { + setErrorPopoverOpen(open); + + // If user manually closes it, remember the current error state to prevent reopening + if (!open) { + // For machine errors, track the error code + if (hasError(machineError)) { + setDismissedErrorCode(machineError); + } + // Update refs so we don't reopen for the same error message/pyodide error + prevErrorMessageRef.current = machineErrorMessage; + prevPyodideErrorRef.current = pyodideError; + } + }; + return (
@@ -166,22 +237,22 @@ export function AppHeader() { )} {/* Error indicator - always render to prevent layout shift */} - + - + - {/* Error popover content */} + {/* Error popover content - unchanged */} {(machineErrorMessage || pyodideError) && ( ({ machineStatus: state.machineStatus, isConnected: state.isConnected, - machineError: state.machineError, - error: state.error, })), ); @@ -297,7 +258,6 @@ export function WorkflowStepper() { // Derived state: pattern is uploaded if machine has pattern info const patternUploaded = usePatternUploaded(); const hasPattern = pesData !== null; - const hasErrorFlag = hasError(machineError); const currentStep = getCurrentStep( machineStatus, isConnected, @@ -443,13 +403,7 @@ export function WorkflowStepper() { aria-label="Step guidance" > {(() => { - const content = getGuideContent( - popoverStep, - machineStatus, - hasErrorFlag, - machineError, - errorMessage || undefined, - ); + const content = getGuideContent(popoverStep, machineStatus); if (!content) return null; const colorClasses = { diff --git a/src/utils/errorCodeHelpers.test.ts b/src/utils/errorCodeHelpers.test.ts new file mode 100644 index 0000000..1699fc6 --- /dev/null +++ b/src/utils/errorCodeHelpers.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { getErrorDetails, SewingMachineError } from "./errorCodeHelpers"; + +describe("errorCodeHelpers", () => { + describe("shortName validation", () => { + it("should ensure all error shortNames are 15 characters or less", () => { + // Get all error codes except None (0xDD) and Unknown (0xEE) since they might not have details + const errorCodes = Object.values(SewingMachineError).filter( + (code) => + code !== SewingMachineError.None && + code !== SewingMachineError.Unknown && + code !== SewingMachineError.OtherError, + ); + + const violations: Array<{ + code: number; + shortName: string; + length: number; + }> = []; + + errorCodes.forEach((code) => { + const details = getErrorDetails(code); + if (details?.shortName) { + const length = details.shortName.length; + if (length > 15) { + violations.push({ + code, + shortName: details.shortName, + length, + }); + } + } + }); + + // If there are violations, create a helpful error message + if (violations.length > 0) { + const violationMessages = violations + .map( + (v) => + `Error code 0x${v.code.toString(16).toUpperCase()}: "${v.shortName}" (${v.length} chars)`, + ) + .join("\n "); + + expect.fail( + `The following error shortNames exceed 15 characters:\n ${violationMessages}`, + ); + } + + // Assertion to confirm test ran + expect(violations).toHaveLength(0); + }); + + it("should ensure all error details have a shortName", () => { + // Get all error codes except None (0xDD) and Unknown (0xEE) + const errorCodes = Object.values(SewingMachineError).filter( + (code) => + code !== SewingMachineError.None && + code !== SewingMachineError.Unknown && + code !== SewingMachineError.OtherError, + ); + + const missing: number[] = []; + + errorCodes.forEach((code) => { + const details = getErrorDetails(code); + if (details && !details.shortName) { + missing.push(code); + } + }); + + if (missing.length > 0) { + const missingCodes = missing + .map((code) => `0x${code.toString(16).toUpperCase()}`) + .join(", "); + + expect.fail( + `The following error codes are missing shortName: ${missingCodes}`, + ); + } + + expect(missing).toHaveLength(0); + }); + }); +}); diff --git a/src/utils/errorCodeHelpers.ts b/src/utils/errorCodeHelpers.ts index a0d3e0d..5c13548 100644 --- a/src/utils/errorCodeHelpers.ts +++ b/src/utils/errorCodeHelpers.ts @@ -42,6 +42,8 @@ export const SewingMachineError = { */ interface ErrorInfo { title: string; + /** Short name for badge display (max 15 characters) */ + shortName: string; description: string; solutions: string[]; /** If true, this "error" is really just an informational step, not a real error */ @@ -55,12 +57,14 @@ interface ErrorInfo { const ERROR_DETAILS: Record = { [SewingMachineError.NeedlePositionError]: { title: "The Needle is Down", + shortName: "Needle Down", description: "The needle is in the down position and needs to be raised before continuing.", solutions: ["Press the needle position switch to raise the needle"], }, [SewingMachineError.SafetyError]: { title: "Safety Error", + shortName: "Safety Error", description: "The machine is sensing an operational issue.", solutions: [ "Remove the thread on the top of the fabric and then remove the needle", @@ -72,21 +76,25 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.LowerThreadSafetyError]: { title: "Lower Thread Safety Error", + shortName: "Lower Thread", description: "The bobbin winder safety device is activated.", solutions: ["Check if the thread is tangled"], }, [SewingMachineError.LowerThreadFreeError]: { title: "Lower Thread Free Error", + shortName: "Lower Thread", description: "Problem with lower thread.", solutions: ["Slide the bobbin winder shaft toward the front"], }, [SewingMachineError.RestartError10]: { title: "Restart Required", + shortName: "Restart Needed", description: "A malfunction occurred.", solutions: ["Turn the machine off, then on again"], }, [SewingMachineError.RestartError11]: { title: "Restart Required (M519411)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519411", solutions: [ "Turn the machine off, then on again", @@ -95,6 +103,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError12]: { title: "Restart Required (M519412)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519412", solutions: [ "Turn the machine off, then on again", @@ -103,6 +112,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError13]: { title: "Restart Required (M519413)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519413", solutions: [ "Turn the machine off, then on again", @@ -111,6 +121,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError14]: { title: "Restart Required (M519414)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519414", solutions: [ "Turn the machine off, then on again", @@ -119,6 +130,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError15]: { title: "Restart Required (M519415)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519415", solutions: [ "Turn the machine off, then on again", @@ -127,6 +139,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError16]: { title: "Restart Required (M519416)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519416", solutions: [ "Turn the machine off, then on again", @@ -135,6 +148,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError17]: { title: "Restart Required (M519417)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519417", solutions: [ "Turn the machine off, then on again", @@ -143,6 +157,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError18]: { title: "Restart Required (M519418)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519418", solutions: [ "Turn the machine off, then on again", @@ -151,6 +166,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError19]: { title: "Restart Required (M519419)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M519419", solutions: [ "Turn the machine off, then on again", @@ -159,6 +175,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError1A]: { title: "Restart Required (M51941A)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M51941A", solutions: [ "Turn the machine off, then on again", @@ -167,6 +184,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError1B]: { title: "Restart Required (M51941B)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M51941B", solutions: [ "Turn the machine off, then on again", @@ -175,6 +193,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RestartError1C]: { title: "Restart Required (M51941C)", + shortName: "Restart Needed", description: "A malfunction occurred. Error code: M51941C", solutions: [ "Turn the machine off, then on again", @@ -183,6 +202,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.NeedlePlateError]: { title: "Needle Plate Error", + shortName: "Needle Plate", description: "Check the needle plate cover.", solutions: [ "Reattach the needle plate cover", @@ -191,11 +211,13 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.ThreadLeverError]: { title: "Thread Lever Error", + shortName: "Thread Lever", description: "The needle threading lever is not in its original position.", solutions: ["Return the needle threading lever to its original position"], }, [SewingMachineError.UpperThreadError]: { title: "Upper Thread Error", + shortName: "Upper Thread", description: "Check and rethread the upper thread.", solutions: [ "Check the upper thread and rethread it", @@ -204,6 +226,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.LowerThreadError]: { title: "Lower Thread Error", + shortName: "Lower Thread", description: "The bobbin thread is almost empty.", solutions: [ "Replace the bobbin thread", @@ -212,6 +235,7 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.UpperThreadSewingStartError]: { title: "Upper Thread Error at Sewing Start", + shortName: "Upper Thread", description: "Check and rethread the upper thread.", solutions: [ "Press the Accept button to resolve the error", @@ -221,21 +245,25 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.PRWiperError]: { title: "PR Wiper Error", + shortName: "PR Wiper", description: "PR Wiper Error.", solutions: ["Press the Accept button to resolve the error"], }, [SewingMachineError.HoopError]: { title: "Hoop Error", + shortName: "Hoop Error", description: "This embroidery frame cannot be used.", solutions: ["Use another frame that fits the pattern"], }, [SewingMachineError.NoHoopError]: { title: "No Hoop Detected", + shortName: "No Hoop", description: "No hoop attached.", solutions: ["Attach the embroidery hoop"], }, [SewingMachineError.InitialHoopError]: { title: "Machine Initialization Required", + shortName: "Init Required", description: "An initial homing procedure must be performed.", solutions: [ "Remove the embroidery hoop from the machine completely", @@ -248,12 +276,14 @@ const ERROR_DETAILS: Record = { }, [SewingMachineError.RegularInspectionError]: { title: "Regular Inspection Required", + shortName: "Inspection Due", description: "Preventive maintenance is recommended. This message is displayed when maintenance is due.", solutions: ["Please contact the service center"], }, [SewingMachineError.Setting]: { title: "Settings Error", + shortName: "Settings Error", description: "Stitch count cannot be changed.", solutions: ["This setting cannot be modified at this time"], },