diff --git a/doc/specs/stdlib_datetime.md b/doc/specs/stdlib_datetime.md index a561f67e5..97ffe736c 100644 --- a/doc/specs/stdlib_datetime.md +++ b/doc/specs/stdlib_datetime.md @@ -243,6 +243,14 @@ Pure function. #### Description Formats a `timedelta_type` as a human-readable string. +For zero-day durations, compact forms are used: +- `5ms` for sub-second values +- `1.500s` or `42s` for sub-minute values +- `MM:SS` when hours are zero +- `HH:MM:SS[.mmm]` otherwise + +For nonzero-day durations, the verbose form is preserved: +`X days, HH:MM:SS[.mmm]`. #### Syntax @@ -250,7 +258,7 @@ Formats a `timedelta_type` as a human-readable string. #### Return value -`character(:), allocatable` — e.g. `"30 days, 01:30:00"`. +`character(:), allocatable` — e.g. `"5ms"`, `"1.500s"`, `"05:30"`, `"01:02:03.456"`, or `"30 days, 01:30:00"`. ## Utility Functions diff --git a/example/datetime/example_datetime_usage.f90 b/example/datetime/example_datetime_usage.f90 index 113cf508a..1eaeb48ec 100644 --- a/example/datetime/example_datetime_usage.f90 +++ b/example/datetime/example_datetime_usage.f90 @@ -65,7 +65,19 @@ program example_datetime t3 = datetime_type(2026, 3, 17, 17, 30, 0, 0, 330) print '(A,L1)', '12:00Z == 17:30+05:30? ', t2 == t3 - ! 9. Unix epoch + ! 9. Compact format_timedelta (sub-second / sub-minute) + print * + print '(A)', '=== Compact Duration Formatting ===' + duration = timedelta(milliseconds=5) + print '(A,T40,A)', 'timedelta(ms=5):', format_timedelta(duration) + duration = timedelta(milliseconds=1500) + print '(A,T40,A)', 'timedelta(ms=1500):', format_timedelta(duration) + duration = timedelta(seconds=65) + print '(A,T40,A)', 'timedelta(s=65):', format_timedelta(duration) + duration = timedelta(hours=1, minutes=2, seconds=3) + print '(A,T40,A)', 'timedelta(1h 2m 3s):', format_timedelta(duration) + + ! 10. Unix epoch print * print '(A,A)', 'Unix epoch: ', & format_datetime(epoch()) diff --git a/src/datetime/stdlib_datetime.f90 b/src/datetime/stdlib_datetime.f90 index c15e03e93..95b6f2a5b 100644 --- a/src/datetime/stdlib_datetime.f90 +++ b/src/datetime/stdlib_datetime.f90 @@ -340,21 +340,59 @@ pure function format_timedelta(td) result(str) !! version: experimental !! !! Format a timedelta_type as a readable string. + !! When days == 0, sub-second/sub-minute durations are formatted + !! compactly (e.g. "5ms", "1.500s", "05:30"). The "0 days, " + !! prefix is omitted. When days /= 0, original verbose format + !! is preserved. type(timedelta_type), intent(in) :: td character(:), allocatable :: str - integer :: h, m, s + integer :: h, m, s, ms + + h = td%seconds / 3600 + m = mod(td%seconds, 3600) / 60 + s = mod(td%seconds, 60) + ms = td%milliseconds + + if (td%days == 0) then + ! Compact formatting for zero-day durations + if (h == 0 .and. m == 0 .and. s == 0) then + if (ms == 0) then + str = "0s" + else + str = to_string(ms, '(I0)') // "ms" + end if + return + end if - h = td%seconds / 3600 - m = mod(td%seconds, 3600) / 60 - s = mod(td%seconds, 60) + if (h == 0 .and. m == 0) then + if (ms == 0) then + str = to_string(s, '(I0)') // "s" + else + str = to_string(s, '(I0)') // "." // to_string(ms, '(I3.3)') // "s" + end if + return + end if - str = to_string(td%days, '(I0)') // ' days, ' // & - to_string(h, '(I2.2)') // ':' // & - to_string(m, '(I2.2)') // ':' // & - to_string(s, '(I2.2)') + if (h > 0) then + str = to_string(h, '(I2.2)') // ":" // & + to_string(m, '(I2.2)') // ":" // & + to_string(s, '(I2.2)') + else + str = to_string(m, '(I2.2)') // ":" // & + to_string(s, '(I2.2)') + end if - if (td%milliseconds /= 0) then - str = str // '.' // to_string(td%milliseconds, '(I3.3)') + if (ms /= 0) str = str // "." // to_string(ms, '(I3.3)') + else + ! Original format for durations with days + str = to_string(td%days, '(I0)') // ' days, ' // & + to_string(h, '(I2.2)') // ':' // & + to_string(m, '(I2.2)') // ':' // & + to_string(s, '(I2.2)') + + if (ms /= 0) then + str = str // '.' // to_string(ms, '(I3.3)') + end if end if end function format_timedelta diff --git a/test/datetime/test_datetime.f90 b/test/datetime/test_datetime.f90 index e22344c38..933490f6d 100644 --- a/test/datetime/test_datetime.f90 +++ b/test/datetime/test_datetime.f90 @@ -71,6 +71,8 @@ subroutine collect_datetime(testsuite) test_format_datetime_offset), & new_unittest("format_timedelta_test", & test_format_timedelta), & + new_unittest("format_timedelta_compact", & + test_format_timedelta_compact), & new_unittest("timedelta_ms_rollover", & test_timedelta_ms_rollover), & new_unittest("to_utc_test", & @@ -505,13 +507,97 @@ end subroutine test_format_datetime_offset subroutine test_format_timedelta(error) type(error_type), allocatable, intent(out) :: error type(timedelta_type) :: td + + ! Legacy: days present -> "X days, HH:MM:SS" td = timedelta(days=30, hours=1, minutes=30) call check(error, & format_timedelta(td) == '30 days, 01:30:00', & - "timedelta format should be '30 days, 01:30:00'") + "[30d 1h 30m] -> '30 days, 01:30:00'") + if (allocated(error)) return + + ! Days + hours + seconds + td = timedelta(days=2, hours=5, seconds=30) + call check(error, & + format_timedelta(td) == '2 days, 05:00:30', & + "[2d 5h 30s] -> '2 days, 05:00:30'") + if (allocated(error)) return + + ! Days + milliseconds: legacy days format keeps .mmm suffix + td = timedelta(days=2, hours=5, seconds=30, milliseconds=125) + call check(error, & + format_timedelta(td) == '2 days, 05:00:30.125', & + "[2d 5h 30s 125ms] -> '2 days, 05:00:30.125'") + if (allocated(error)) return + + ! No days: hours only + td = timedelta(hours=1, minutes=2, seconds=3) + call check(error, & + format_timedelta(td) == '01:02:03', & + "[1h 2m 3s] -> '01:02:03'") + if (allocated(error)) return + + ! No days, no hours: MM:SS + td = timedelta(minutes=5, seconds=30) + call check(error, & + format_timedelta(td) == '05:30', & + "[5m 30s] -> '05:30'") + if (allocated(error)) return + + ! With milliseconds + td = timedelta(hours=1, minutes=2, seconds=3, milliseconds=456) + call check(error, & + format_timedelta(td) == '01:02:03.456', & + "[1h 2m 3s 456ms] -> '01:02:03.456'") if (allocated(error)) return end subroutine test_format_timedelta +subroutine test_format_timedelta_compact(error) + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td + + ! Sub-second: milliseconds only + td = timedelta(milliseconds=5) + call check(error, & + format_timedelta(td) == '5ms', & + "[5ms] -> '5ms'") + if (allocated(error)) return + + ! Sub-second: zero + td = timedelta(milliseconds=0) + call check(error, & + format_timedelta(td) == '0s', & + "[0ms] -> '0s'") + if (allocated(error)) return + + ! Sub-minute: seconds with ms fraction + td = timedelta(milliseconds=1500) + call check(error, & + format_timedelta(td) == '1.500s', & + "[1500ms] -> '1.500s'") + if (allocated(error)) return + + ! Sub-minute: whole seconds + td = timedelta(seconds=42) + call check(error, & + format_timedelta(td) == '42s', & + "[42s] -> '42s'") + if (allocated(error)) return + + ! Minute-second compact form with milliseconds + td = timedelta(minutes=5, seconds=30, milliseconds=125) + call check(error, & + format_timedelta(td) == '05:30.125', & + "[5m 30s 125ms] -> '05:30.125'") + if (allocated(error)) return + + ! Negative duration: preserves original format since days /= 0 + td = timedelta(seconds=-1) + call check(error, & + format_timedelta(td) == '-1 days, 23:59:59', & + "[-1s] -> '-1 days, 23:59:59' (negative via days component)") + if (allocated(error)) return +end subroutine test_format_timedelta_compact + subroutine test_timedelta_ms_rollover(error) type(error_type), allocatable, intent(out) :: error type(datetime_type) :: dt, res