@wandelbots/wandelbots-js-react-components 2.34.1-pr.feature-robot-precondition-list.372.c1de8ff → 2.34.1-pr.feature-robot-precondition-list.372.90c151f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wandelbots/wandelbots-js-react-components",
3
- "version": "2.34.1-pr.feature-robot-precondition-list.372.c1de8ff",
3
+ "version": "2.34.1-pr.feature-robot-precondition-list.372.90c151f",
4
4
  "description": "React UI toolkit for building applications on top of the Wandelbots platform",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -1,4 +1,4 @@
1
- import { Box, Typography, useTheme } from "@mui/material"
1
+ import { Box, Fade, Typography, useTheme } from "@mui/material"
2
2
  import { Gauge } from "@mui/x-charts/Gauge"
3
3
  import { observer } from "mobx-react-lite"
4
4
  import { useCallback, useEffect, useRef, useState } from "react"
@@ -9,13 +9,13 @@ import { useInterpolation } from "./utils/interpolation"
9
9
  export interface CycleTimerProps {
10
10
  /**
11
11
  * Callback that receives the timer control functions:
12
- * - `startNewCycle(maxTimeSeconds, elapsedSeconds?)` - Start a new timer cycle
12
+ * - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (if maxTimeSeconds is omitted, runs as count-up timer)
13
13
  * - `pause()` - Pause the countdown while preserving remaining time
14
14
  * - `resume()` - Resume countdown from where it was paused
15
15
  * - `isPaused()` - Check current pause state
16
16
  */
17
17
  onCycleComplete: (controls: {
18
- startNewCycle: (maxTimeSeconds: number, elapsedSeconds?: number) => void
18
+ startNewCycle: (maxTimeSeconds?: number, elapsedSeconds?: number) => void
19
19
  pause: () => void
20
20
  resume: () => void
21
21
  isPaused: () => boolean
@@ -30,18 +30,23 @@ export interface CycleTimerProps {
30
30
  compact?: boolean
31
31
  /** Additional CSS classes */
32
32
  className?: string
33
+ /** Whether the timer is in an error state (pauses timer and shows error styling) */
34
+ hasError?: boolean
33
35
  }
34
36
 
35
37
  /**
36
- * A circular gauge timer component that shows the remaining time of a cycle
38
+ * A circular gauge timer component that shows the remaining time of a cycle or counts up
37
39
  *
38
40
  * Features:
39
41
  * - Circular gauge with 264px diameter and 40px thickness
40
- * - Shows remaining time prominently in the center (60px font)
41
- * - Displays "remaining time" label at top and total time at bottom
42
- * - Automatically counts down and triggers callback when reaching zero
42
+ * - Two modes: count-down (with max time) or count-up (without max time)
43
+ * - Count-down mode: shows remaining time prominently, counts down to zero
44
+ * - Count-up mode: shows elapsed time, gauge progresses in minute steps
45
+ * - Displays appropriate labels based on mode
46
+ * - Automatically counts down/up and triggers callback when reaching zero (count-down only)
43
47
  * - Full timer control: start, pause, resume functionality
44
48
  * - Support for starting with elapsed time (resume mid-cycle)
49
+ * - Error state support: pauses timer and shows error styling (red color)
45
50
  * - Smooth spring-based progress animations for all state transitions
46
51
  * - Fully localized with i18next
47
52
  * - Material-UI theming integration
@@ -53,32 +58,44 @@ export interface CycleTimerProps {
53
58
  * @param variant - Visual variant: "default" (large gauge) or "small" (animated icon with text)
54
59
  * @param compact - For small variant: whether to hide remaining time details
55
60
  * @param className - Additional CSS classes
61
+ * @param hasError - Whether the timer is in an error state (pauses timer and shows error styling)
56
62
  *
57
63
  * Usage:
58
64
  * ```tsx
65
+ * // Count-down timer (with max time)
59
66
  * <CycleTimer
60
67
  * onCycleComplete={(controls) => {
61
- * // Start a 5-minute cycle
68
+ * // Start a 5-minute countdown cycle
62
69
  * controls.startNewCycle(300)
63
70
  *
64
71
  * // Or start a 5-minute cycle with 2 minutes already elapsed
65
72
  * controls.startNewCycle(300, 120)
73
+ * }}
74
+ * onCycleEnd={() => console.log('Cycle completed!')}
75
+ * />
66
76
  *
67
- * // Pause the timer
68
- * controls.pause()
77
+ * // Count-up timer (no max time)
78
+ * <CycleTimer
79
+ * onCycleComplete={(controls) => {
80
+ * // Start count-up timer
81
+ * controls.startNewCycle()
69
82
  *
70
- * // Resume the timer
71
- * controls.resume()
83
+ * // Or start count-up timer with some elapsed time
84
+ * controls.startNewCycle(undefined, 120)
85
+ * }}
86
+ * />
72
87
  *
73
- * // Check if paused
74
- * const paused = controls.isPaused()
88
+ * // Timer with error state
89
+ * <CycleTimer
90
+ * hasError={errorCondition}
91
+ * onCycleComplete={(controls) => {
92
+ * controls.startNewCycle(300)
75
93
  * }}
76
- * onCycleEnd={() => console.log('Cycle completed!')}
77
94
  * />
78
95
  * ```
79
96
  *
80
97
  * Control Functions:
81
- * - `startNewCycle(maxTimeSeconds, elapsedSeconds?)` - Start a new timer cycle
98
+ * - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (omit maxTimeSeconds for count-up mode)
82
99
  * - `pause()` - Pause the countdown while preserving remaining time
83
100
  * - `resume()` - Resume countdown from where it was paused
84
101
  * - `isPaused()` - Check current pause state
@@ -92,17 +109,23 @@ export const CycleTimer = externalizeComponent(
92
109
  variant = "default",
93
110
  compact = false,
94
111
  className,
112
+ hasError = false,
95
113
  }: CycleTimerProps) => {
96
114
  const theme = useTheme()
97
115
  const { t } = useTranslation()
98
116
  const [remainingTime, setRemainingTime] = useState(0)
99
- const [maxTime, setMaxTime] = useState(0)
117
+ const [maxTime, setMaxTime] = useState<number | null>(null)
100
118
  const [isRunning, setIsRunning] = useState(false)
101
119
  const [isPausedState, setIsPausedState] = useState(false)
102
120
  const [currentProgress, setCurrentProgress] = useState(0)
103
121
  const animationRef = useRef<number | null>(null)
104
122
  const startTimeRef = useRef<number | null>(null)
105
123
  const pausedTimeRef = useRef<number>(0)
124
+ const [wasRunningBeforeError, setWasRunningBeforeError] = useState(false)
125
+
126
+ // Track mode changes for fade transitions
127
+ const [showLabels, setShowLabels] = useState(true)
128
+ const prevMaxTimeRef = useRef<number | null | undefined>(undefined)
106
129
 
107
130
  // Spring-based interpolator for smooth gauge progress animations
108
131
  // Uses physics simulation to create natural, smooth transitions between progress values
@@ -114,37 +137,78 @@ export const CycleTimer = externalizeComponent(
114
137
  },
115
138
  })
116
139
 
140
+ // Handle mode changes with fade transitions for labels only
141
+ useEffect(() => {
142
+ const currentIsCountUp = maxTime === null
143
+ const prevMaxTime = prevMaxTimeRef.current
144
+ const prevIsCountUp = prevMaxTime === null
145
+
146
+ // Check if mode actually changed (not just first render)
147
+ if (
148
+ prevMaxTimeRef.current !== undefined &&
149
+ prevIsCountUp !== currentIsCountUp
150
+ ) {
151
+ // Mode changed - labels will fade based on the Fade component conditions
152
+ // We just need to ensure showLabels is true so Fade can control visibility
153
+ setShowLabels(true)
154
+ } else {
155
+ // No mode change or first time - set initial state
156
+ setShowLabels(true)
157
+ }
158
+
159
+ prevMaxTimeRef.current = maxTime
160
+ }, [maxTime])
161
+
117
162
  const startNewCycle = useCallback(
118
- (maxTimeSeconds: number, elapsedSeconds: number = 0) => {
119
- setMaxTime(maxTimeSeconds)
120
- const remainingSeconds = Math.max(0, maxTimeSeconds - elapsedSeconds)
121
- setRemainingTime(remainingSeconds)
163
+ (maxTimeSeconds?: number, elapsedSeconds: number = 0) => {
164
+ setMaxTime(maxTimeSeconds ?? null)
122
165
  setIsPausedState(false)
123
166
  pausedTimeRef.current = 0
124
167
 
125
- // Animate progress smoothly to starting position
126
- // For new cycles (no elapsed time), animate from current position to 0%
127
- // For resumed cycles, animate to the appropriate progress percentage
128
- const initialProgress =
129
- elapsedSeconds > 0 ? (elapsedSeconds / maxTimeSeconds) * 100 : 0
130
- if (elapsedSeconds === 0) {
131
- progressInterpolator.setTarget([0])
168
+ if (maxTimeSeconds !== undefined) {
169
+ // Count-down mode: set remaining time
170
+ const remainingSeconds = Math.max(
171
+ 0,
172
+ maxTimeSeconds - elapsedSeconds,
173
+ )
174
+ setRemainingTime(remainingSeconds)
175
+
176
+ // Animate progress smoothly to starting position
177
+ const initialProgress =
178
+ elapsedSeconds > 0 ? (elapsedSeconds / maxTimeSeconds) * 100 : 0
179
+ if (elapsedSeconds === 0) {
180
+ progressInterpolator.setTarget([0])
181
+ } else {
182
+ progressInterpolator.setTarget([initialProgress])
183
+ }
184
+
185
+ if (remainingSeconds === 0) {
186
+ setIsRunning(false)
187
+ startTimeRef.current = null
188
+ // Trigger completion callback immediately if time is already up
189
+ if (onCycleEnd) {
190
+ setTimeout(() => onCycleEnd(), 0)
191
+ }
192
+ } else if (autoStart) {
193
+ startTimeRef.current = Date.now() - elapsedSeconds * 1000
194
+ setIsRunning(true)
195
+ } else {
196
+ startTimeRef.current = null
197
+ }
132
198
  } else {
199
+ // Count-up mode: start from elapsed time
200
+ setRemainingTime(elapsedSeconds)
201
+
202
+ // For count-up mode, progress is based on minute steps
203
+ const initialProgress = ((elapsedSeconds / 60) % 1) * 100
133
204
  progressInterpolator.setTarget([initialProgress])
134
- }
135
205
 
136
- if (remainingSeconds === 0) {
137
- setIsRunning(false)
138
- startTimeRef.current = null
139
- // Trigger completion callback immediately if time is already up
140
- if (onCycleEnd) {
141
- setTimeout(() => onCycleEnd(), 0)
206
+ if (autoStart) {
207
+ startTimeRef.current = Date.now() - elapsedSeconds * 1000
208
+ setIsRunning(true)
209
+ } else {
210
+ startTimeRef.current = null
142
211
  }
143
- } else if (autoStart) {
144
- startTimeRef.current = Date.now() - elapsedSeconds * 1000
145
- setIsRunning(true)
146
- } else {
147
- startTimeRef.current = null
148
212
  }
149
213
  },
150
214
  [autoStart, onCycleEnd, progressInterpolator],
@@ -159,8 +223,16 @@ export const CycleTimer = externalizeComponent(
159
223
  // Calculate exact progress position and smoothly animate to it when pausing
160
224
  // This ensures the visual progress matches the actual elapsed time
161
225
  const totalElapsed = pausedTimeRef.current / 1000
162
- const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
163
- progressInterpolator.setTarget([exactProgress])
226
+
227
+ if (maxTime !== null) {
228
+ // Count-down mode
229
+ const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
230
+ progressInterpolator.setTarget([exactProgress])
231
+ } else {
232
+ // Count-up mode: progress based on minute steps
233
+ const exactProgress = ((totalElapsed / 60) % 1) * 100
234
+ progressInterpolator.setTarget([exactProgress])
235
+ }
164
236
  }
165
237
  setIsRunning(false)
166
238
  setIsPausedState(true)
@@ -178,6 +250,30 @@ export const CycleTimer = externalizeComponent(
178
250
  return isPausedState
179
251
  }, [isPausedState])
180
252
 
253
+ // Handle error state changes
254
+ useEffect(() => {
255
+ if (hasError) {
256
+ // Error occurred - pause timer if running and remember state
257
+ if (isRunning && !isPausedState) {
258
+ setWasRunningBeforeError(true)
259
+ pause()
260
+ }
261
+ } else {
262
+ // Error resolved - resume if was running before error
263
+ if (wasRunningBeforeError && isPausedState) {
264
+ setWasRunningBeforeError(false)
265
+ resume()
266
+ }
267
+ }
268
+ }, [
269
+ hasError,
270
+ isRunning,
271
+ isPausedState,
272
+ wasRunningBeforeError,
273
+ pause,
274
+ resume,
275
+ ])
276
+
181
277
  // Call onCycleComplete immediately to provide the timer control functions
182
278
  useEffect(() => {
183
279
  let isMounted = true
@@ -202,30 +298,39 @@ export const CycleTimer = externalizeComponent(
202
298
  if (isRunning) {
203
299
  // Single animation frame loop that handles both time updates and progress
204
300
  const updateTimer = () => {
205
- if (startTimeRef.current && maxTime > 0) {
301
+ if (startTimeRef.current) {
206
302
  const now = Date.now()
207
303
  const elapsed =
208
304
  (now - startTimeRef.current + pausedTimeRef.current) / 1000
209
- const remaining = Math.max(0, maxTime - elapsed)
210
-
211
- // Update remaining time based on timestamp calculation
212
- setRemainingTime(Math.ceil(remaining))
213
-
214
- // Smoothly animate progress based on elapsed time for fluid visual feedback
215
- const progress = Math.min(100, (elapsed / maxTime) * 100)
216
- progressInterpolator.setTarget([progress])
217
-
218
- if (remaining <= 0) {
219
- setIsRunning(false)
220
- startTimeRef.current = null
221
- setRemainingTime(0)
222
- // Animate to 100% completion with smooth spring transition
223
- progressInterpolator.setTarget([100])
224
- // Call onCycleEnd when timer reaches zero to notify about completion
225
- if (onCycleEnd) {
226
- setTimeout(() => onCycleEnd(), 0)
305
+
306
+ if (maxTime !== null) {
307
+ // Count-down mode
308
+ const remaining = Math.max(0, maxTime - elapsed)
309
+ setRemainingTime(Math.ceil(remaining))
310
+
311
+ // Smoothly animate progress based on elapsed time for fluid visual feedback
312
+ const progress = Math.min(100, (elapsed / maxTime) * 100)
313
+ progressInterpolator.setTarget([progress])
314
+
315
+ if (remaining <= 0) {
316
+ setIsRunning(false)
317
+ startTimeRef.current = null
318
+ setRemainingTime(0)
319
+ // Animate to 100% completion with smooth spring transition
320
+ progressInterpolator.setTarget([100])
321
+ // Call onCycleEnd when timer reaches zero to notify about completion
322
+ if (onCycleEnd) {
323
+ setTimeout(() => onCycleEnd(), 0)
324
+ }
325
+ return
227
326
  }
228
- return
327
+ } else {
328
+ // Count-up mode
329
+ setRemainingTime(Math.floor(elapsed))
330
+
331
+ // For count-up mode, progress completes every minute (0-100% per minute)
332
+ const progress = ((elapsed / 60) % 1) * 100
333
+ progressInterpolator.setTarget([progress])
229
334
  }
230
335
 
231
336
  // Continue animation loop while running
@@ -272,9 +377,16 @@ export const CycleTimer = externalizeComponent(
272
377
  // Keep interpolator synchronized with static progress when timer is stopped
273
378
  // Ensures correct visual state when component initializes or timer stops
274
379
  useEffect(() => {
275
- if (!isRunning && !isPausedState && maxTime > 0) {
276
- const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
277
- progressInterpolator.setTarget([staticProgress])
380
+ if (!isRunning && !isPausedState) {
381
+ if (maxTime !== null && maxTime > 0) {
382
+ // Count-down mode
383
+ const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
384
+ progressInterpolator.setTarget([staticProgress])
385
+ } else if (maxTime === null) {
386
+ // Count-up mode
387
+ const staticProgress = ((remainingTime / 60) % 1) * 100
388
+ progressInterpolator.setTarget([staticProgress])
389
+ }
278
390
  }
279
391
  }, [
280
392
  isRunning,
@@ -331,7 +443,11 @@ export const CycleTimer = externalizeComponent(
331
443
  cy="10"
332
444
  r="8"
333
445
  fill="none"
334
- stroke={theme.palette.success.main}
446
+ stroke={
447
+ hasError
448
+ ? theme.palette.error.light
449
+ : theme.palette.success.main
450
+ }
335
451
  strokeWidth="2"
336
452
  opacity={0.3}
337
453
  />
@@ -341,7 +457,11 @@ export const CycleTimer = externalizeComponent(
341
457
  cy="10"
342
458
  r="8"
343
459
  fill="none"
344
- stroke={theme.palette.success.main}
460
+ stroke={
461
+ hasError
462
+ ? theme.palette.error.light
463
+ : theme.palette.success.main
464
+ }
345
465
  strokeWidth="2"
346
466
  strokeLinecap="round"
347
467
  strokeDasharray={`${2 * Math.PI * 8}`}
@@ -361,11 +481,15 @@ export const CycleTimer = externalizeComponent(
361
481
  fontSize: "14px",
362
482
  }}
363
483
  >
364
- {compact
365
- ? // Compact mode: show remaining time with "min." suffix
366
- `${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
367
- : // Full mode: show "remaining / of total min." format
368
- `${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`}
484
+ {maxTime !== null
485
+ ? // Count-down mode: show remaining time
486
+ compact
487
+ ? // Compact mode: show remaining time with "min." suffix
488
+ `${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
489
+ : // Full mode: show "remaining / of total min." format
490
+ `${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`
491
+ : // Count-up mode: show elapsed time only
492
+ formatTime(remainingTime)}
369
493
  </Typography>
370
494
  </Box>
371
495
  )
@@ -397,7 +521,9 @@ export const CycleTimer = externalizeComponent(
397
521
  opacity: isPausedState ? 0.6 : 1,
398
522
  transition: "opacity 0.2s ease",
399
523
  [`& .MuiGauge-valueArc`]: {
400
- fill: theme.palette.success.main,
524
+ fill: hasError
525
+ ? theme.palette.error.light
526
+ : theme.palette.success.main,
401
527
  },
402
528
  [`& .MuiGauge-referenceArc`]: {
403
529
  fill: "white",
@@ -425,19 +551,30 @@ export const CycleTimer = externalizeComponent(
425
551
  gap: 1,
426
552
  }}
427
553
  >
428
- {/* "remaining time" label */}
429
- <Typography
430
- variant="body2"
554
+ {/* "remaining time" label - always reserves space to prevent layout shift */}
555
+ <Box
431
556
  sx={{
432
- fontSize: "12px",
433
- color: theme.palette.text.secondary,
557
+ height: "16px", // Fixed height to prevent layout shift
558
+ display: "flex",
559
+ alignItems: "center",
560
+ justifyContent: "center",
434
561
  marginBottom: 0.5,
435
562
  }}
436
563
  >
437
- {t("CycleTimer.RemainingTime.lb")}
438
- </Typography>
564
+ <Fade in={showLabels && maxTime !== null} timeout={300}>
565
+ <Typography
566
+ variant="body2"
567
+ sx={{
568
+ fontSize: "12px",
569
+ color: theme.palette.text.secondary,
570
+ }}
571
+ >
572
+ {t("CycleTimer.RemainingTime.lb")}
573
+ </Typography>
574
+ </Fade>
575
+ </Box>
439
576
 
440
- {/* Main timer display */}
577
+ {/* Main timer display - never fades, always visible */}
441
578
  <Typography
442
579
  variant="h1"
443
580
  sx={{
@@ -451,16 +588,29 @@ export const CycleTimer = externalizeComponent(
451
588
  {formatTime(remainingTime)}
452
589
  </Typography>
453
590
 
454
- {/* Total time display */}
455
- <Typography
456
- variant="body2"
591
+ {/* Total time display - always reserves space to prevent layout shift */}
592
+ <Box
457
593
  sx={{
458
- fontSize: "12px",
459
- color: theme.palette.text.secondary,
594
+ height: "16px", // Fixed height to prevent layout shift
595
+ display: "flex",
596
+ alignItems: "center",
597
+ justifyContent: "center",
460
598
  }}
461
599
  >
462
- {t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })}
463
- </Typography>
600
+ <Fade in={showLabels && maxTime !== null} timeout={300}>
601
+ <Typography
602
+ variant="body2"
603
+ sx={{
604
+ fontSize: "12px",
605
+ color: theme.palette.text.secondary,
606
+ }}
607
+ >
608
+ {maxTime !== null
609
+ ? t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })
610
+ : ""}
611
+ </Typography>
612
+ </Fade>
613
+ </Box>
464
614
  </Box>
465
615
  </Box>
466
616
  )