Skip to content

Commit 99fab73

Browse files
feat: enhance timer components with localized time formatting (#396)
[Storybook Link](https://wandelbotsgmbh.github.io/wandelbots-js-react-components/overview.html) --------- Co-authored-by: Copilot <[email protected]>
1 parent a91c5f0 commit 99fab73

File tree

5 files changed

+228
-75
lines changed

5 files changed

+228
-75
lines changed

src/components/CycleTimer/SmallVariant.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,19 +202,19 @@ export const SmallVariant = ({
202202
</>
203203
) : currentState === "measuring" ? (
204204
compact ? (
205-
`${formatTimeLocalized(remainingTime, i18n.language)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
205+
formatTimeLocalized(remainingTime, i18n.language)
206206
) : (
207207
`${formatTimeLocalized(remainingTime, i18n.language)} / ${t("CycleTimer.Measuring.lb", "measuring...")}`
208208
)
209209
) : currentState === "measured" ? (
210210
compact ? (
211-
`${formatTimeLocalized(remainingTime, i18n.language)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
211+
formatTimeLocalized(remainingTime, i18n.language)
212212
) : (
213213
`${formatTimeLocalized(remainingTime, i18n.language)} / ${t("CycleTimer.Determined.lb", "determined")}`
214214
)
215215
) : currentState === "countdown" && maxTime !== null ? (
216216
compact ? (
217-
`${formatTimeLocalized(remainingTime, i18n.language)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
217+
formatTimeLocalized(remainingTime, i18n.language)
218218
) : (
219219
`${formatTimeLocalized(remainingTime, i18n.language)} / ${t("CycleTimer.Time.lb", { time: formatTimeLocalized(maxTime, i18n.language) })}`
220220
)

src/components/Timer/TimerSmallVariant.tsx

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

77
interface TimerSmallVariantProps {
88
timerState: TimerState
@@ -19,7 +19,7 @@ export const TimerSmallVariant = ({
1919
compact,
2020
className,
2121
}: TimerSmallVariantProps) => {
22-
const { t } = useTranslation()
22+
const { t, i18n } = useTranslation()
2323
const theme = useTheme()
2424
const { elapsedTime, currentProgress } = timerState
2525
const { showErrorAnimation, showPauseAnimation } = animationState
@@ -45,7 +45,9 @@ export const TimerSmallVariant = ({
4545
transition: "color 0.5s ease-out",
4646
}}
4747
>
48-
{hasError ? t("timer.error") : formatTime(elapsedTime)}
48+
{hasError
49+
? t("timer.error")
50+
: formatTimeLocalized(elapsedTime, i18n.language)}
4951
</Typography>
5052
</Box>
5153
)
@@ -87,9 +89,7 @@ export const TimerSmallVariant = ({
8789
r="8"
8890
fill="none"
8991
stroke={
90-
hasError
91-
? theme.palette.error.light
92-
: theme.palette.success.main
92+
hasError ? theme.palette.error.light : theme.palette.success.main
9393
}
9494
strokeWidth="2"
9595
opacity={0.3}
@@ -103,9 +103,7 @@ export const TimerSmallVariant = ({
103103
r="8"
104104
fill="none"
105105
stroke={
106-
hasError
107-
? theme.palette.error.light
108-
: theme.palette.success.main
106+
hasError ? theme.palette.error.light : theme.palette.success.main
109107
}
110108
strokeWidth="2"
111109
strokeLinecap="round"
@@ -133,7 +131,9 @@ export const TimerSmallVariant = ({
133131
"color 0.8s ease-in-out, font-size 0.3s ease-out, opacity 2s ease-in-out",
134132
}}
135133
>
136-
{hasError ? t("timer.error") : formatTime(elapsedTime)}
134+
{hasError
135+
? t("timer.error")
136+
: formatTimeLocalized(elapsedTime, i18n.language)}
137137
</Typography>
138138
</Box>
139139
)

src/components/Timer/utils.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,82 @@
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
59+
// TODO: Remove suppression once Intl.DurationFormat is supported in TypeScript types.
60+
// See: https://github.com/microsoft/TypeScript/issues/53971
61+
// DurationFormat is a proposed API and not yet available in TypeScript's standard library types.
62+
const formatter = new Intl.DurationFormat(locale, { style: "narrow" })
63+
return formatter.format(duration)
64+
} catch {
65+
// Fall through to manual formatting
66+
}
67+
}
68+
69+
// Manual formatting with compact units
70+
const parts: string[] = []
71+
72+
if (days > 0) parts.push(`${days}d`)
73+
if (hours > 0) parts.push(`${hours}h`)
74+
if (minutes > 0) parts.push(`${minutes}m`)
75+
if (remainingSeconds > 0 || parts.length === 0) {
76+
parts.push(`${remainingSeconds}s`)
77+
}
78+
79+
return parts.join(" ")
880
}
981

1082
/**

stories/CycleTimer.stories.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,3 +423,115 @@ export const SmallVariant: Story = {
423423
)
424424
},
425425
}
426+
427+
/**
428+
* Compact small variant showing only the localized time without additional labels.
429+
* Perfect for space-constrained UIs where you need just the essential time display.
430+
*/
431+
export const CompactVariant: Story = {
432+
args: {
433+
variant: "small",
434+
compact: true,
435+
autoStart: true,
436+
},
437+
render: function Render(args) {
438+
const controlsRef = React.useRef<{
439+
startNewCycle: (maxTimeSeconds: number, elapsedSeconds?: number) => void
440+
startMeasuring: (elapsedSeconds?: number) => void
441+
setIdle: () => void
442+
completeMeasuring: () => void
443+
pause: () => void
444+
resume: () => void
445+
isPaused: () => boolean
446+
} | null>(null)
447+
448+
const handleCycleComplete = (controls: {
449+
startNewCycle: (maxTimeSeconds: number, elapsedSeconds?: number) => void
450+
startMeasuring: (elapsedSeconds?: number) => void
451+
setIdle: () => void
452+
completeMeasuring: () => void
453+
pause: () => void
454+
resume: () => void
455+
isPaused: () => boolean
456+
}) => {
457+
controlsRef.current = controls
458+
args.onCycleComplete(controls)
459+
}
460+
461+
const startMeasuring = () => {
462+
if (controlsRef.current) {
463+
controlsRef.current.startMeasuring()
464+
}
465+
}
466+
467+
const startMeasuringHours = () => {
468+
if (controlsRef.current) {
469+
controlsRef.current.startMeasuring(7384) // ~2h 3m 4s elapsed
470+
}
471+
}
472+
473+
const startCountdown = () => {
474+
if (controlsRef.current) {
475+
controlsRef.current.startNewCycle(60) // 60 second countdown
476+
}
477+
}
478+
479+
const startCountdownHours = () => {
480+
if (controlsRef.current) {
481+
controlsRef.current.startNewCycle(7200) // 2 hour countdown
482+
}
483+
}
484+
485+
const startCountdownDays = () => {
486+
if (controlsRef.current) {
487+
controlsRef.current.startNewCycle(90000) // ~25 hour countdown (1d 1h)
488+
}
489+
}
490+
491+
return (
492+
<Box
493+
sx={{
494+
display: "flex",
495+
flexDirection: "column",
496+
alignItems: "flex-start",
497+
gap: 3,
498+
}}
499+
>
500+
<CycleTimer {...args} onCycleComplete={handleCycleComplete} />
501+
502+
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
503+
<Button variant="contained" onClick={startMeasuring} size="small">
504+
Start Measuring
505+
</Button>
506+
<Button variant="contained" onClick={startCountdown} size="small">
507+
Start 60s Countdown
508+
</Button>
509+
<Button
510+
variant="contained"
511+
onClick={startMeasuringHours}
512+
size="small"
513+
color="secondary"
514+
>
515+
Measuring ~2h 3m
516+
</Button>
517+
<Button
518+
variant="contained"
519+
onClick={startCountdownHours}
520+
size="small"
521+
color="secondary"
522+
>
523+
Countdown 2h
524+
</Button>
525+
<Button
526+
variant="contained"
527+
onClick={startCountdownDays}
528+
size="small"
529+
color="secondary"
530+
>
531+
Countdown ~25h
532+
</Button>
533+
</Box>
534+
</Box>
535+
)
536+
},
537+
}

0 commit comments

Comments
 (0)