Compare commits

...

6 commits

Author SHA1 Message Date
Jan-Henrik Bruhn
72c6cf6eea
Merge pull request #24 from jhbruhn/fix/pattern-time-calculation
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
fix: Correct total time calculation and add detailed logging
2025-12-21 13:50:02 +01:00
d2bcf603b3 fix: Correct total time calculation and add detailed logging
Fix bug where total pattern time only included blocks up to current position,
and add comprehensive step-by-step logging for analysis.

Bug Fix:
- Separate total time calculation from elapsed time calculation
- Total time now correctly sums ALL color blocks regardless of current position
- Previously broke early when finding current position, missing remaining blocks

Logging Added:
- Step 1: Log calculation of total time across all blocks
- Step 2: Log calculation of elapsed time based on current stitch position
- Detailed per-block logging showing stitch counts, time conversions, and cumulative values
- Final result summary with total, elapsed, and remaining minutes

This allows proper analysis of the Brother PP1 timing formula:
((stitchCount - 1) * 150ms + 3000ms) / 60000, rounded up to minutes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 13:48:19 +01:00
Jan-Henrik Bruhn
91d248db6f
Merge pull request #23 from jhbruhn/feature/shadcn
fix: ui updates and canvas overflow
2025-12-21 13:34:20 +01:00
a0cb6d1822 fix: Prevent PatternCanvas overflow on small screens
Replace fixed height values with flexbox sizing to prevent canvas overflow
when the window height is constrained.

Changes:
- Replace h-[400px] sm:h-[500px] lg:flex-1 with flex-1 on all screen sizes
- Add min-h-0 to CardContent and canvas container for proper flex shrinking
- Canvas now properly respects card boundaries on all screen sizes

The flex-1 min-h-0 pattern ensures the canvas takes available space without
overflowing its parent card, regardless of viewport size.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 13:32:13 +01:00
a077dea68e feature: Replace connection indicator with shadcn StatusIndicator
Replace the custom connection indicator dots with the shadcn StatusIndicator
component for better visual consistency and state indication.

Changes:
- Install status-indicator component from 8starlabs shadcn registry
- Add getStatusIndicatorState() helper to map MachineStateCategory to StatusIndicator states
- Replace connection indicator divs with StatusIndicator component in AppHeader
- Connection indicator now shows state-dependent colors:
  - Green (active): Connected and ready, sewing, or complete
  - Yellow (fixing): Connected and waiting for user action
  - Red (down): Connected but interrupted or in error state
  - Gray (idle): Disconnected
- Remove unused color prop from StatusIndicator component

The StatusIndicator provides animated visual feedback for different machine
states, making it easier for users to understand the current system status at
a glance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 13:29:27 +01:00
4992c33bf1 feature: Migrate ProgressMonitor color blocks to shadcn ScrollArea
Replace custom scrollable div implementation with shadcn ScrollArea component
for the color blocks list in ProgressMonitor. This improves code maintainability
and provides consistent styling across the application.

Changes:
- Install @radix-ui/react-scroll-area via shadcn CLI
- Add scroll-area.tsx component with proper @/ path alias
- Replace custom scrollable div with ScrollArea wrapper
- Remove manual scroll handling:
  - Removed showGradient state and useState import
  - Removed colorBlocksScrollRef ref
  - Removed handleColorBlocksScroll function
  - Removed resize listener useEffect
  - Removed gradient overlay div
- Fix ScrollArea height constraint with lg:h-0 for proper flexbox scrolling
- Simplify component structure with 50+ fewer lines of scroll handling code

The ScrollArea component provides better accessibility and consistent scrollbar
styling while eliminating the need for manual scroll position tracking.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 13:20:12 +01:00
9 changed files with 341 additions and 61 deletions

53
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
@ -3614,6 +3615,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@ -3773,6 +3780,21 @@
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
@ -4096,6 +4118,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",

View file

@ -27,6 +27,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",

View file

@ -3,7 +3,10 @@ import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore";
import { WorkflowStepper } from "./WorkflowStepper";
import { ErrorPopoverContent } from "./ErrorPopover";
import { getStateVisualInfo } from "../utils/machineStateHelpers";
import {
getStateVisualInfo,
getStatusIndicatorState,
} from "../utils/machineStateHelpers";
import {
CheckCircleIcon,
BoltIcon,
@ -14,6 +17,7 @@ import {
} from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import StatusIndicator from "@/components/ui/status-indicator";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import {
Tooltip,
@ -66,20 +70,18 @@ export function AppHeader() {
};
const StatusIcon = stateIcons[stateVisual.iconName];
// Get connection indicator state (idle when disconnected, state-dependent when connected)
const connectionIndicatorState = isConnected
? getStatusIndicatorState(machineStatus)
: "idle";
return (
<TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
{/* Machine Connection Status - Responsive width column */}
<div className="flex items-center gap-3 w-full lg:w-[280px]">
<div
className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50"
style={{ visibility: isConnected ? "visible" : "hidden" }}
></div>
<div
className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5"
style={{ visibility: !isConnected ? "visible" : "hidden" }}
></div>
<StatusIndicator state={connectionIndicatorState} size="sm" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">

View file

@ -283,9 +283,9 @@ export function PatternCanvas() {
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col">
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col min-h-0">
<div
className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
className="relative w-full flex-1 min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
ref={containerRef}
>
{containerSize.width > 0 && (

View file

@ -1,4 +1,4 @@
import { useRef, useEffect, useState, useMemo } from "react";
import { useRef, useEffect, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { usePatternStore } from "../stores/usePatternStore";
@ -26,6 +26,7 @@ import {
} 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
@ -52,8 +53,6 @@ export function ProgressMonitor() {
// Pattern store
const pesData = usePatternStore((state) => state.pesData);
const currentBlockRef = useRef<HTMLDivElement>(null);
const colorBlocksScrollRef = useRef<HTMLDivElement>(null);
const [showGradient, setShowGradient] = useState(true);
// State indicators
const isMaskTraceComplete =
@ -135,31 +134,6 @@ export function ProgressMonitor() {
}
}, [currentBlockIndex]);
// Handle scroll to detect if at bottom
const handleColorBlocksScroll = () => {
if (colorBlocksScrollRef.current) {
const { scrollTop, scrollHeight, clientHeight } =
colorBlocksScrollRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold
setShowGradient(!isAtBottom);
}
};
// Check initial scroll state and update on resize
useEffect(() => {
const checkScrollable = () => {
if (colorBlocksScrollRef.current) {
const { scrollHeight, clientHeight } = colorBlocksScrollRef.current;
const isScrollable = scrollHeight > clientHeight;
setShowGradient(isScrollable);
}
};
checkScrollable();
window.addEventListener("resize", checkScrollable);
return () => window.removeEventListener("resize", checkScrollable);
}, [colorBlocks]);
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">
@ -242,12 +216,8 @@ export function ProgressMonitor() {
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
Color Blocks
</h4>
<div className="relative lg:flex-1 lg:min-h-0">
<div
ref={colorBlocksScrollRef}
onScroll={handleColorBlocksScroll}
className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full"
>
<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;
@ -362,11 +332,7 @@ export function ProgressMonitor() {
);
})}
</div>
{/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */}
{showGradient && (
<div className="hidden lg:block absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white dark:from-gray-800 to-transparent pointer-events-none" />
)}
</div>
</ScrollArea>
</div>
)}

View file

@ -0,0 +1,56 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View file

@ -0,0 +1,84 @@
import React from "react";
import { cn } from "@/lib/utils";
interface StatusIndicatorProps {
state: "active" | "down" | "fixing" | "idle";
label?: string;
className?: string;
size?: "sm" | "md" | "lg";
labelClassName?: string;
}
const getStateColors = (state: StatusIndicatorProps["state"]) => {
switch (state) {
case "active":
return { dot: "bg-green-500", ping: "bg-green-300" };
case "down":
return { dot: "bg-red-500", ping: "bg-red-300" };
case "fixing":
return { dot: "bg-yellow-500", ping: "bg-yellow-300" };
case "idle":
default:
return { dot: "bg-slate-700", ping: "bg-slate-400" };
}
};
const getSizeClasses = (size: StatusIndicatorProps["size"]) => {
switch (size) {
case "sm":
return { dot: "h-2 w-2", ping: "h-2 w-2" };
case "lg":
return { dot: "h-4 w-4", ping: "h-4 w-4" };
case "md":
default:
return { dot: "h-3 w-3", ping: "h-3 w-3" };
}
};
const StatusIndicator: React.FC<StatusIndicatorProps> = ({
state = "idle",
label,
className,
size = "md",
labelClassName,
}) => {
const shouldAnimate =
state === "active" || state === "fixing" || state === "down";
const colors = getStateColors(state);
const sizeClasses = getSizeClasses(size);
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex items-center">
{shouldAnimate && (
<span
className={cn(
"absolute inline-flex rounded-full opacity-75 animate-ping",
sizeClasses.ping,
colors.ping,
)}
/>
)}
<span
className={cn(
"relative inline-flex rounded-full",
sizeClasses.dot,
colors.dot,
)}
/>
</div>
{label && (
<p
className={cn(
"text-sm text-slate-700 dark:text-slate-300",
labelClassName,
)}
>
{label}
</p>
)}
</div>
);
};
export default StatusIndicator;

View file

@ -205,3 +205,30 @@ export function getStateVisualInfo(status: MachineStatus): StateVisualInfo {
return visualMap[category];
}
/**
* Map machine state category to status indicator state.
* Returns the appropriate state for the StatusIndicator component.
*/
export function getStatusIndicatorState(
status: MachineStatus,
): "active" | "down" | "fixing" | "idle" {
const category = getMachineStateCategory(status);
switch (category) {
case MachineStateCategory.IDLE:
return "active"; // Gray, no animation
case MachineStateCategory.ACTIVE:
return "active"; // Green with animation
case MachineStateCategory.WAITING:
return "fixing"; // Yellow with animation
case MachineStateCategory.COMPLETE:
return "active"; // Green with animation
case MachineStateCategory.INTERRUPTED:
return "down"; // Red with animation
case MachineStateCategory.ERROR:
return "down"; // Red with animation
default:
return "idle";
}
}

View file

@ -5,13 +5,26 @@
* - 3000ms startup time
* - Result in minutes (rounded up)
*/
export function convertStitchesToMinutes(stitchCount: number): number {
if (stitchCount <= 1) return 0;
export function convertStitchesToMinutes(
stitchCount: number,
logPrefix = "",
): number {
if (stitchCount <= 1) {
console.log(
`${logPrefix}[convertStitchesToMinutes] stitchCount=${stitchCount} <= 1, returning 0`,
);
return 0;
}
const timeMs = (stitchCount - 1) * 150 + 3000;
const timeMin = Math.ceil(timeMs / 60000);
const result = timeMin < 1 ? 1 : timeMin;
return timeMin < 1 ? 1 : timeMin;
console.log(
`${logPrefix}[convertStitchesToMinutes] stitchCount=${stitchCount}, timeMs=${timeMs}, timeMin=${timeMin}, result=${result}`,
);
return result;
}
/**
@ -26,36 +39,114 @@ export function calculatePatternTime(
elapsedMinutes: number;
remainingMinutes: number;
} {
console.log(
`\n[calculatePatternTime] Starting calculation with ${colorBlocks.length} blocks, currentStitch=${currentStitch}`,
);
// Step 1: Calculate total time for ALL blocks
console.log("\n[calculatePatternTime] STEP 1: Calculating total time");
let totalMinutes = 0;
for (let i = 0; i < colorBlocks.length; i++) {
const block = colorBlocks[i];
const blockTime = convertStitchesToMinutes(
block.stitchCount,
` Total Block ${i + 1} `,
);
totalMinutes += blockTime;
console.log(
` [calculatePatternTime] Block ${i + 1}: ${block.stitchCount} stitches = ${blockTime} min. Total now: ${totalMinutes} min`,
);
}
console.log(
`[calculatePatternTime] Total time for all blocks: ${totalMinutes} min`,
);
// Step 2: Calculate elapsed time based on currentStitch
console.log(
`\n[calculatePatternTime] STEP 2: Calculating elapsed time for currentStitch=${currentStitch}`,
);
let elapsedMinutes = 0;
let cumulativeStitches = 0;
// Calculate time per color block
for (const block of colorBlocks) {
totalMinutes += convertStitchesToMinutes(block.stitchCount);
for (let i = 0; i < colorBlocks.length; i++) {
const block = colorBlocks[i];
const prevCumulativeStitches = cumulativeStitches;
cumulativeStitches += block.stitchCount;
console.log(
`\n[calculatePatternTime] Block ${i + 1}/${colorBlocks.length}: stitchCount=${block.stitchCount}`,
);
console.log(
` [calculatePatternTime] Cumulative stitches: ${prevCumulativeStitches} + ${block.stitchCount} = ${cumulativeStitches}`,
);
if (cumulativeStitches < currentStitch) {
// This entire block is completed
elapsedMinutes += convertStitchesToMinutes(block.stitchCount);
console.log(
` [calculatePatternTime] Block completed (${cumulativeStitches} < ${currentStitch})`,
);
const elapsed = convertStitchesToMinutes(
block.stitchCount,
` Elapsed Block ${i + 1} `,
);
elapsedMinutes += elapsed;
console.log(
` [calculatePatternTime] Added ${elapsed} min to elapsed. Elapsed now: ${elapsedMinutes} min`,
);
} else if (cumulativeStitches === currentStitch) {
// We just completed this block
elapsedMinutes += convertStitchesToMinutes(block.stitchCount);
console.log(
` [calculatePatternTime] Block just completed (${cumulativeStitches} === ${currentStitch})`,
);
const elapsed = convertStitchesToMinutes(
block.stitchCount,
` Elapsed Block ${i + 1} `,
);
elapsedMinutes += elapsed;
console.log(
` [calculatePatternTime] Added ${elapsed} min to elapsed. Elapsed now: ${elapsedMinutes} min`,
);
console.log(
` [calculatePatternTime] Breaking elapsed calculation at block ${i + 1}`,
);
break;
} else {
// We're partway through this block
// We're partway through this block (or haven't started)
const stitchesInBlock =
currentStitch - (cumulativeStitches - block.stitchCount);
elapsedMinutes += convertStitchesToMinutes(stitchesInBlock);
console.log(
` [calculatePatternTime] Partway through block (${cumulativeStitches} > ${currentStitch})`,
);
console.log(
` [calculatePatternTime] Stitches in this block: ${currentStitch} - ${cumulativeStitches - block.stitchCount} = ${stitchesInBlock}`,
);
const elapsed = convertStitchesToMinutes(
stitchesInBlock,
` Elapsed Partial Block ${i + 1} `,
);
elapsedMinutes += elapsed;
console.log(
` [calculatePatternTime] Added ${elapsed} min to elapsed. Elapsed now: ${elapsedMinutes} min`,
);
console.log(
` [calculatePatternTime] Breaking elapsed calculation at block ${i + 1}`,
);
break;
}
}
return {
const result = {
totalMinutes,
elapsedMinutes,
remainingMinutes: Math.max(0, totalMinutes - elapsedMinutes),
};
console.log(`\n[calculatePatternTime] Final result:`, result);
console.log(
` Total: ${result.totalMinutes} min, Elapsed: ${result.elapsedMinutes} min, Remaining: ${result.remainingMinutes} min\n`,
);
return result;
}
/**