Skip to content

Commit a91c5f0

Browse files
feat: enhance CycleTimer with localized time formatting and new utili… (#395)
…ty functions [Storybook Link](https://wandelbotsgmbh.github.io/wandelbots-js-react-components/overview.html)
1 parent 6a4f817 commit a91c5f0

File tree

6 files changed

+172
-54
lines changed

6 files changed

+172
-54
lines changed

src/components/CycleTimer/DefaultVariant.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Box, Fade, Typography, useTheme } from "@mui/material"
22
import { Gauge } from "@mui/x-charts/Gauge"
33
import { useTranslation } from "react-i18next"
44
import type { AnimationState, TimerState } from "./types"
5-
import { formatTime } from "./utils"
5+
import { formatTime, formatTimeLocalized } from "./utils"
66

77
interface DefaultVariantProps {
88
timerState: TimerState
@@ -17,7 +17,7 @@ export const DefaultVariant = ({
1717
hasError,
1818
className,
1919
}: DefaultVariantProps) => {
20-
const { t } = useTranslation()
20+
const { t, i18n } = useTranslation()
2121
const theme = useTheme()
2222
const { currentState, remainingTime, maxTime, currentProgress } = timerState
2323
const {
@@ -310,7 +310,7 @@ export const DefaultVariant = ({
310310
? t("CycleTimer.Determined.lb", "determined")
311311
: currentState === "countdown" && maxTime !== null
312312
? t("CycleTimer.OfTime.lb", {
313-
time: formatTime(maxTime),
313+
time: formatTimeLocalized(maxTime, i18n.language),
314314
})
315315
: ""}
316316
</span>

src/components/CycleTimer/SmallVariant.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Box, Typography, useTheme } from "@mui/material"
22
import { useTranslation } from "react-i18next"
33
import type { AnimationState, TimerState } from "./types"
4-
import { formatTime } from "./utils"
4+
import { formatTimeLocalized } from "./utils"
55

66
interface SmallVariantProps {
77
timerState: TimerState
@@ -18,7 +18,7 @@ export const SmallVariant = ({
1818
compact,
1919
className,
2020
}: SmallVariantProps) => {
21-
const { t } = useTranslation()
21+
const { t, i18n } = useTranslation()
2222
const theme = useTheme()
2323
const { currentState, remainingTime, maxTime } = timerState
2424
const {
@@ -54,8 +54,8 @@ export const SmallVariant = ({
5454
{hasError
5555
? t("CycleTimer.Error.lb", "Error")
5656
: currentState === "idle"
57-
? "0:00"
58-
: formatTime(remainingTime)}
57+
? "0s"
58+
: formatTimeLocalized(remainingTime, i18n.language)}
5959
</Typography>
6060
</Box>
6161
)
@@ -202,24 +202,24 @@ export const SmallVariant = ({
202202
</>
203203
) : currentState === "measuring" ? (
204204
compact ? (
205-
`${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
205+
`${formatTimeLocalized(remainingTime, i18n.language)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
206206
) : (
207-
`${formatTime(remainingTime)} / ${t("CycleTimer.Measuring.lb", "measuring...")}`
207+
`${formatTimeLocalized(remainingTime, i18n.language)} / ${t("CycleTimer.Measuring.lb", "measuring...")}`
208208
)
209209
) : currentState === "measured" ? (
210210
compact ? (
211-
`${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
211+
`${formatTimeLocalized(remainingTime, i18n.language)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
212212
) : (
213-
`${formatTime(remainingTime)} / ${t("CycleTimer.Determined.lb", "determined")}`
213+
`${formatTimeLocalized(remainingTime, i18n.language)} / ${t("CycleTimer.Determined.lb", "determined")}`
214214
)
215215
) : currentState === "countdown" && maxTime !== null ? (
216216
compact ? (
217-
`${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
217+
`${formatTimeLocalized(remainingTime, i18n.language)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
218218
) : (
219-
`${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`
219+
`${formatTimeLocalized(remainingTime, i18n.language)} / ${t("CycleTimer.Time.lb", { time: formatTimeLocalized(maxTime, i18n.language) })}`
220220
)
221221
) : (
222-
formatTime(remainingTime)
222+
formatTimeLocalized(remainingTime, i18n.language)
223223
)}
224224
</Typography>
225225
</Box>

src/components/CycleTimer/utils.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,79 @@
11
/**
2-
* Formats time in seconds to MM:SS format
2+
* Formats time in seconds to D:HH:MM:SS, H:MM:SS or MM:SS format
3+
* Used for the default (large) timer variant
4+
* Automatically includes days and hours as needed for clarity
35
*/
46
export const formatTime = (seconds: number): string => {
5-
const minutes = Math.floor(seconds / 60)
7+
const days = Math.floor(seconds / 86400)
8+
const hours = Math.floor((seconds % 86400) / 3600)
9+
const minutes = Math.floor((seconds % 3600) / 60)
610
const remainingSeconds = seconds % 60
7-
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`
11+
12+
// Build time parts array
13+
const parts: string[] = []
14+
15+
if (days > 0) {
16+
parts.push(days.toString())
17+
parts.push(hours.toString().padStart(2, "0"))
18+
parts.push(minutes.toString().padStart(2, "0"))
19+
parts.push(remainingSeconds.toString().padStart(2, "0"))
20+
} else if (hours > 0) {
21+
parts.push(hours.toString())
22+
parts.push(minutes.toString().padStart(2, "0"))
23+
parts.push(remainingSeconds.toString().padStart(2, "0"))
24+
} else {
25+
parts.push(minutes.toString())
26+
parts.push(remainingSeconds.toString().padStart(2, "0"))
27+
}
28+
29+
return parts.join(":")
30+
}
31+
32+
/**
33+
* Formats time in seconds to a localized human-readable format
34+
* Used for the small timer variant
35+
* Examples: "2h 30m 15s", "45m 30s", "30s"
36+
* Falls back to English units if Intl.DurationFormat is not available
37+
*/
38+
export const formatTimeLocalized = (
39+
seconds: number,
40+
locale?: string,
41+
): string => {
42+
const days = Math.floor(seconds / 86400)
43+
const hours = Math.floor((seconds % 86400) / 3600)
44+
const minutes = Math.floor((seconds % 3600) / 60)
45+
const remainingSeconds = seconds % 60
46+
47+
// Try using Intl.DurationFormat if available (newer browsers)
48+
if (typeof Intl !== "undefined" && "DurationFormat" in Intl) {
49+
try {
50+
const duration: Record<string, number> = {}
51+
if (days > 0) duration.days = days
52+
if (hours > 0) duration.hours = hours
53+
if (minutes > 0) duration.minutes = minutes
54+
if (remainingSeconds > 0 || Object.keys(duration).length === 0) {
55+
duration.seconds = remainingSeconds
56+
}
57+
58+
// @ts-expect-error - DurationFormat is not yet in TypeScript types
59+
const formatter = new Intl.DurationFormat(locale, { style: "narrow" })
60+
return formatter.format(duration)
61+
} catch {
62+
// Fall through to manual formatting
63+
}
64+
}
65+
66+
// Manual formatting with compact units
67+
const parts: string[] = []
68+
69+
if (days > 0) parts.push(`${days}d`)
70+
if (hours > 0) parts.push(`${hours}h`)
71+
if (minutes > 0) parts.push(`${minutes}m`)
72+
if (remainingSeconds > 0 || parts.length === 0) {
73+
parts.push(`${remainingSeconds}s`)
74+
}
75+
76+
return parts.join(" ")
877
}
978

1079
/**

src/i18n/locales/de/translations.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@
4545
"Jogging.Joints.bt": "Gelenke",
4646
"Jogging.Velocity.bt": "Geschwindigkeit",
4747
"CycleTimer.RemainingTime.lb": "Verbleibende Zeit",
48-
"CycleTimer.OfTime.lb": "von {{time}} min.",
49-
"CycleTimer.Time.lb": "{{time}} min.",
48+
"CycleTimer.OfTime.lb": "von {{time}}",
49+
"CycleTimer.Time.lb": "{{time}}",
5050
"CycleTimer.Error.lb": "Fehler",
5151
"CycleTimer.WaitingForCycle.lb": "Warten auf Programmzyklus",
5252
"CycleTimer.CycleTime.lb": "Zykluszeit",

src/i18n/locales/en/translations.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
"Jogging.Joints.bt": "Joints",
4747
"Jogging.Velocity.bt": "Velocity",
4848
"CycleTimer.RemainingTime.lb": "Time remaining",
49-
"CycleTimer.OfTime.lb": "of {{time}} min.",
50-
"CycleTimer.Time.lb": "{{time}} min.",
49+
"CycleTimer.OfTime.lb": "of {{time}}",
50+
"CycleTimer.Time.lb": "{{time}}",
5151
"CycleTimer.Error.lb": "Error",
5252
"CycleTimer.WaitingForCycle.lb": "Waiting for program cycle",
5353
"CycleTimer.CycleTime.lb": "Cycle Time",

stories/CycleTimer.stories.tsx

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,24 @@ export const Default: Story = {
186186
}
187187
}
188188

189+
const startMeasuringHours = () => {
190+
if (controlsRef.current) {
191+
controlsRef.current.startMeasuring(7384) // ~2h 3m 4s elapsed
192+
}
193+
}
194+
195+
const startCountdownHours = () => {
196+
if (controlsRef.current) {
197+
controlsRef.current.startNewCycle(7200) // 2 hour countdown
198+
}
199+
}
200+
201+
const startCountdownDays = () => {
202+
if (controlsRef.current) {
203+
controlsRef.current.startNewCycle(90000) // ~25 hour countdown (1d 1h)
204+
}
205+
}
206+
189207
const pauseTimer = () => {
190208
if (controlsRef.current) {
191209
controlsRef.current.pause()
@@ -225,18 +243,10 @@ export const Default: Story = {
225243
justifyContent: "center",
226244
}}
227245
>
228-
<Button
229-
variant="contained"
230-
onClick={setIdle}
231-
size="small"
232-
>
246+
<Button variant="contained" onClick={setIdle} size="small">
233247
Set Idle
234248
</Button>
235-
<Button
236-
variant="contained"
237-
onClick={startMeasuring}
238-
size="small"
239-
>
249+
<Button variant="contained" onClick={startMeasuring} size="small">
240250
Start Measuring
241251
</Button>
242252
<Button
@@ -246,39 +256,47 @@ export const Default: Story = {
246256
>
247257
Start Measuring (45s)
248258
</Button>
259+
<Button variant="contained" onClick={completeMeasuring} size="small">
260+
Complete Measuring
261+
</Button>
262+
<Button variant="contained" onClick={startCountdown} size="small">
263+
Start 90s Countdown
264+
</Button>
249265
<Button
250266
variant="contained"
251-
onClick={completeMeasuring}
267+
onClick={startCountdownWithOffset}
252268
size="small"
253269
>
254-
Complete Measuring
270+
Start 120s (30s elapsed)
255271
</Button>
256272
<Button
257273
variant="contained"
258-
onClick={startCountdown}
274+
onClick={startMeasuringHours}
259275
size="small"
276+
color="secondary"
260277
>
261-
Start 90s Countdown
278+
Measuring ~2h 3m
262279
</Button>
263280
<Button
264281
variant="contained"
265-
onClick={startCountdownWithOffset}
282+
onClick={startCountdownHours}
266283
size="small"
284+
color="secondary"
267285
>
268-
Start 120s (30s elapsed)
286+
Countdown 2h
269287
</Button>
270288
<Button
271-
variant="outlined"
272-
onClick={pauseTimer}
289+
variant="contained"
290+
onClick={startCountdownDays}
273291
size="small"
292+
color="secondary"
274293
>
294+
Countdown ~25h
295+
</Button>
296+
<Button variant="outlined" onClick={pauseTimer} size="small">
275297
Pause
276298
</Button>
277-
<Button
278-
variant="outlined"
279-
onClick={resumeTimer}
280-
size="small"
281-
>
299+
<Button variant="outlined" onClick={resumeTimer} size="small">
282300
Resume
283301
</Button>
284302
<Button
@@ -334,12 +352,30 @@ export const SmallVariant: Story = {
334352
}
335353
}
336354

355+
const startMeasuringHours = () => {
356+
if (controlsRef.current) {
357+
controlsRef.current.startMeasuring(7384) // ~2h 3m 4s elapsed
358+
}
359+
}
360+
337361
const startCountdown = () => {
338362
if (controlsRef.current) {
339363
controlsRef.current.startNewCycle(60) // 60 second countdown
340364
}
341365
}
342366

367+
const startCountdownHours = () => {
368+
if (controlsRef.current) {
369+
controlsRef.current.startNewCycle(7200) // 2 hour countdown
370+
}
371+
}
372+
373+
const startCountdownDays = () => {
374+
if (controlsRef.current) {
375+
controlsRef.current.startNewCycle(90000) // ~25 hour countdown (1d 1h)
376+
}
377+
}
378+
343379
return (
344380
<Box
345381
sx={{
@@ -349,28 +385,41 @@ export const SmallVariant: Story = {
349385
gap: 3,
350386
}}
351387
>
352-
<CycleTimer
353-
{...args}
354-
onCycleComplete={handleCycleComplete}
355-
/>
388+
<CycleTimer {...args} onCycleComplete={handleCycleComplete} />
356389

357-
<Box sx={{ display: "flex", gap: 1 }}>
390+
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
391+
<Button variant="contained" onClick={startMeasuring} size="small">
392+
Start Measuring
393+
</Button>
394+
<Button variant="contained" onClick={startCountdown} size="small">
395+
Start 60s Countdown
396+
</Button>
358397
<Button
359398
variant="contained"
360-
onClick={startMeasuring}
399+
onClick={startMeasuringHours}
361400
size="small"
401+
color="secondary"
362402
>
363-
Start Measuring
403+
Measuring ~2h 3m
364404
</Button>
365405
<Button
366406
variant="contained"
367-
onClick={startCountdown}
407+
onClick={startCountdownHours}
368408
size="small"
409+
color="secondary"
369410
>
370-
Start 60s Countdown
411+
Countdown 2h
412+
</Button>
413+
<Button
414+
variant="contained"
415+
onClick={startCountdownDays}
416+
size="small"
417+
color="secondary"
418+
>
419+
Countdown ~25h
371420
</Button>
372421
</Box>
373422
</Box>
374423
)
375424
},
376-
}
425+
}

0 commit comments

Comments
 (0)