diff --git a/doc/specs/stdlib_datetime.md b/doc/specs/stdlib_datetime.md new file mode 100644 index 000000000..a561f67e5 --- /dev/null +++ b/doc/specs/stdlib_datetime.md @@ -0,0 +1,387 @@ +--- +title: datetime +--- + +[TOC] + +## Introduction + +The `stdlib_datetime` module provides types and procedures for date, time, and duration handling. It defines two primary derived types — `datetime_type` for representing specific points in time and `timedelta_type` for representing durations — along with arithmetic operators, comparison operators, ISO 8601 parsing/formatting, and calendar utilities. + +No C-bindings or parameterized derived types are required. The module uses only standard Fortran intrinsics, calendar arithmetic, and string manipulation. + +### Status + +Experimental + +## Derived Types + +### `datetime_type` + +Represents a specific point in time. + +| Component | Type | Default | Description | +|-----------|------|---------|-------------| +| `year` | `integer` | 1 | Year (1–9999) | +| `month` | `integer` | 1 | Month (1–12) | +| `day` | `integer` | 1 | Day (1–31) | +| `hour` | `integer` | 0 | Hour (0–23) | +| `minute` | `integer` | 0 | Minute (0–59) | +| `second` | `integer` | 0 | Second (0–59) | +| `millisecond` | `integer` | 0 | Millisecond (0–999) | +| `utc_offset_minutes` | `integer` | 0 | UTC offset in minutes. Minutes are used because all real-world UTC offsets are whole multiples of minutes (ISO 8601 specifies offsets as `±HH:MM`). | + +### `timedelta_type` + +Represents a duration or interval. After normalization, `seconds` is always in [0, 86399] and `milliseconds` is always in [0, 999] (i.e., they are never negative). For negative durations, only the `days` component is negative while `seconds` and `milliseconds` remain non-negative. + +| Component | Type | Default | Description | +|-----------|------|---------|-------------| +| `days` | `integer` | 0 | Number of days (can be negative for negative durations) | +| `seconds` | `integer` | 0 | Seconds, always in [0, 86399] after normalization | +| `milliseconds` | `integer` | 0 | Milliseconds, always in [0, 999] after normalization | + +## Constructors + +### `datetime` — Create from components + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Creates a `datetime_type` from individual components. All arguments are optional; if omitted, they default to the type's initial values: `year=1`, `month=1`, `day=1`, `hour=0`, `minute=0`, `second=0`, `millisecond=0`, `utc_offset_minutes=0`. + +*Note:* Unlike `parse_datetime`, this constructor does not perform strict bounds checking on the provided values (e.g., passing `month=13` is not checked) to remain highly efficient in tight loops. The caller is responsible for ensuring the values form a valid date block. + +#### Syntax + +`dt = ` [[stdlib_datetime(module):datetime(function)]] `([year] [, month] [, day] [, hour] [, minute] [, second] [, millisecond] [, utc_offset_minutes])` + +#### Arguments + +All arguments are optional with `intent(in)` and type `integer`. If no arguments are provided, all components default to the type's initial values (`year=1`, `month=1`, `day=1`, `hour=0`, `minute=0`, `second=0`, `millisecond=0`, `utc_offset_minutes=0`). + +#### Return value + +A `datetime_type` value with components equal to those provided, or to the default ones. + +### `timedelta` — Create from mixed units + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Creates a normalized `timedelta_type`. Accepts mixed units (days, hours, minutes, seconds, milliseconds) and normalizes them. If no arguments are provided, all components default to `0`. + +#### Syntax + +`td = ` [[stdlib_datetime(module):timedelta(function)]] `([days] [, hours] [, minutes] [, seconds] [, milliseconds])` + +#### Arguments + +All arguments are optional with `intent(in)` and type `integer`. If no arguments are provided, all components default to `0`. + +#### Return value + +A normalized `timedelta_type` value with `seconds` in [0, 86399] and `milliseconds` in [0, 999]. + +### `now` — Current local time + +#### Status + +Experimental + +#### Class + +Function (not `pure`; calls the intrinsic `date_and_time` which is impure). + +#### Description + +Returns the current local date and time from the system clock. + +#### Syntax + +`dt = ` [[stdlib_datetime(module):now(function)]] `()` + +### `now_utc` — Current UTC time + +#### Status + +Experimental + +#### Class + +Function (not `pure`; calls `now()` internally). + +#### Description + +Returns the current UTC date and time. + +#### Syntax + +`dt = ` [[stdlib_datetime(module):now_utc(function)]] `()` + +### `epoch` — Unix epoch + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Returns the Unix epoch: `1970-01-01T00:00:00Z`. This is a mathematical constant (a fixed date) and does not depend on the operating system. + +#### Syntax + +`dt = ` [[stdlib_datetime(module):epoch(function)]] `()` + +## Operators + +### Arithmetic + +| Expression | Result Type | Description | +|------------|-------------|-------------| +| `datetime + timedelta` | `datetime_type` | Add duration to timestamp | +| `timedelta + datetime` | `datetime_type` | Commutative form | +| `timedelta + timedelta` | `timedelta_type` | Add two durations | +| `datetime - timedelta` | `datetime_type` | Subtract duration | +| `datetime - datetime` | `timedelta_type` | Difference between two timestamps | +| `timedelta - timedelta` | `timedelta_type` | Subtract durations | +| `-timedelta` | `timedelta_type` | Negate duration | + +### Comparison + +All six comparison operators are provided for both `datetime_type` and `timedelta_type`: `==`, `/=`, `<`, `<=`, `>`, `>=`. + +**Important:** `datetime_type` comparisons convert both operands to UTC internally, so comparing across timezones works correctly. + +## Parsing and Formatting + +### `parse_datetime` — Parse ISO 8601 string + +#### Status + +Experimental + +#### Class + +Function (not `pure`; dummy arguments in a `pure function` must be `intent(in)`, which contradicts the `intent(out)` status argument `stat`). + +#### Description + +Parses an ISO 8601 date/time string into a `datetime_type`. + +#### Syntax + +`dt = ` [[stdlib_datetime(module):parse_datetime(function)]] `(str [, stat])` + +#### Arguments + +`str`: `character(len=*)`, `intent(in)`. The ISO 8601 string to parse. + +`stat` (optional): `integer`, `intent(out)`. Returns 0 on success, non-zero on error. + +#### Supported formats + +- `YYYY-MM-DD` +- `YYYY-MM-DDTHH:MM:SS` +- `YYYY-MM-DDTHH:MM:SSZ` +- `YYYY-MM-DDTHH:MM:SS+HH:MM` +- `YYYY-MM-DDTHH:MM:SS.fZ` (variable precision fractional seconds up to milliseconds) +- `YYYY-MM-DDTHH:MM:SS.f+HH:MM` + +For the `YYYY-MM-DDTHH:MM:SS` form without a timezone designator, the value is interpreted as UTC, and `utc_offset_minutes` is set to `0`. There is currently no cross-platform standard way to get a time zone offset for arbitrary past/future dates, so defaulting to UTC serves as an absolute, safe coordinate. Forms with `Z` or an explicit offset use the specified UTC offset. + +### `format_datetime` — Format as ISO 8601 + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Formats a `datetime_type` as an ISO 8601 string. + +#### Syntax + +`str = ` [[stdlib_datetime(module):format_datetime(function)]] `(dt)` + +#### Return value + +`character(:), allocatable` — e.g. `"2026-03-17T12:00:00Z"` or `"2026-03-17T23:05:15+05:30"`. + +### `format_timedelta` — Format duration + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Formats a `timedelta_type` as a human-readable string. + +#### Syntax + +`str = ` [[stdlib_datetime(module):format_timedelta(function)]] `(td)` + +#### Return value + +`character(:), allocatable` — e.g. `"30 days, 01:30:00"`. + +## Utility Functions + +### `is_leap_year` + +#### Status + +Experimental + +#### Class + +Pure elemental function / interface. + +#### Description + +Returns `.true.` if the given year (or datetime's year) is a leap year. + +#### Syntax + +`result = ` [[stdlib_datetime(module):is_leap_year(interface)]] `(year)` or `(dt)` + +### `days_in_month` + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Returns the number of days in a given month and year. + +#### Syntax + +`d = ` [[stdlib_datetime(module):days_in_month(function)]] `(month, year)` + +### `days_in_year` + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Returns 365 or 366. + +#### Syntax + +`d = ` [[stdlib_datetime(module):days_in_year(function)]] `(year)` + +### `day_of_year` + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Returns the ordinal day (1–366). + +#### Syntax + +`doy = ` [[stdlib_datetime(module):day_of_year(function)]] `(dt)` + +### `day_of_week` + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Returns the ISO weekday (1=Monday, ..., 7=Sunday). + +#### Syntax + +`dow = ` [[stdlib_datetime(module):day_of_week(function)]] `(dt)` + +### `to_utc` + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Converts a `datetime_type` to UTC. + +#### Syntax + +`utc_dt = ` [[stdlib_datetime(module):to_utc(function)]] `(dt)` + +### `total_seconds` + +#### Status + +Experimental + +#### Class + +Pure function. + +#### Description + +Returns the total duration as `real(dp)`. + +#### Syntax + +`secs = ` [[stdlib_datetime(module):total_seconds(function)]] `(td)` + +## Example + +```fortran +{!example/datetime/example_datetime_usage.f90!} +``` diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt index efd1ae171..c2ce46fcf 100644 --- a/example/CMakeLists.txt +++ b/example/CMakeLists.txt @@ -25,6 +25,7 @@ if (STDLIB_BITSETS) add_subdirectory(bitsets) endif() add_subdirectory(constants) +add_subdirectory(datetime) add_subdirectory(error) if (STDLIB_HASHMAPS) add_subdirectory(hashmaps) diff --git a/example/datetime/CMakeLists.txt b/example/datetime/CMakeLists.txt new file mode 100644 index 000000000..b2603ed5b --- /dev/null +++ b/example/datetime/CMakeLists.txt @@ -0,0 +1 @@ +ADD_EXAMPLE(datetime_usage) diff --git a/example/datetime/example_datetime_usage.f90 b/example/datetime/example_datetime_usage.f90 new file mode 100644 index 000000000..113cf508a --- /dev/null +++ b/example/datetime/example_datetime_usage.f90 @@ -0,0 +1,76 @@ +program example_datetime + !! Demonstrate the stdlib_datetime module functionality. + use stdlib_datetime + implicit none + + type(datetime_type) :: t1, t2, t3 + type(timedelta_type) :: duration + integer :: stat + + print '(A)', '=== stdlib_datetime Example ===' + print * + + ! 1. Get the current local time + t1 = now() + print '(A,A)', 'Current local time: ', format_datetime(t1) + + ! 2. Get the current UTC time + t2 = now_utc() + print '(A,A)', 'Current UTC time: ', format_datetime(t2) + + ! 3. Parse an ISO 8601 string + t2 = parse_datetime('2026-03-17T12:00:00Z', stat) + if (stat /= 0) then + print '(A)', 'ERROR: Failed to parse date string!' + stop 1 + end if + print '(A,A)', 'Parsed datetime: ', format_datetime(t2) + + ! 4. Calculate the difference between two datetimes + duration = t1 - t2 + print '(A,A)', 'Duration (t1-t2): ', & + format_timedelta(duration) + + ! 5. Add 30 days to a date + t3 = t2 + timedelta_type(30, 0, 0) + print '(A,A)', 'After adding 30d: ', format_datetime(t3) + + ! 6. Add mixed units (1 day, 6 hours, 30 minutes) + t3 = t2 + timedelta(days=1, hours=6, minutes=30) + print '(A,A)', 'After +1d 6h 30m: ', format_datetime(t3) + + ! 7. Calendar utilities + print * + print '(A)', '=== Calendar Utilities ===' + print '(A,L1)', 'Is 2024 leap year? ', is_leap_year(2024) + print '(A,L1)', 'Is 2026 leap year? ', is_leap_year(2026) + print '(A,I0)', 'Days in Feb 2024: ', & + days_in_month(2, 2024) + print '(A,I0)', 'Days in Feb 2026: ', & + days_in_month(2, 2026) + print '(A,I0)', 'Day of year (t2): ', day_of_year(t2) + print '(A,I0)', 'Day of week (t2): ', day_of_week(t2) + print '(A)', ' (1=Mon, 2=Tue, ..., 7=Sun)' + + ! 8. Comparison operators + print * + print '(A)', '=== Comparisons ===' + t2 = parse_datetime('2026-03-17T12:00:00Z') + t3 = parse_datetime('2026-03-18T12:00:00Z') + print '(A,L1)', 'Mar17 < Mar18? ', t2 < t3 + print '(A,L1)', 'Mar17 == Mar17? ', t2 == t2 + + ! Cross-timezone equality: 12:00 UTC == 17:30 IST + t2 = datetime_type(2026, 3, 17, 12, 0, 0, 0, 0) + 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 + print * + print '(A,A)', 'Unix epoch: ', & + format_datetime(epoch()) + + print * + print '(A)', 'Done!' + +end program example_datetime diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f955ed352..114b22b42 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,6 +18,7 @@ add_subdirectory(blas) add_subdirectory(constants) add_subdirectory(core) + add_subdirectory(hash) ADD_SUBDIR(hashmaps) @@ -39,6 +40,7 @@ add_subdirectory(specialfunctions) ADD_SUBDIR(specialmatrices) ADD_SUBDIR(stringlist) add_subdirectory(strings) +add_subdirectory(datetime) ADD_SUBDIR(system) ADD_SUBDIR(stats) @@ -58,6 +60,7 @@ configure_stdlib_target(${PROJECT_NAME} f90Files fppFiles cppFiles) target_link_libraries(${PROJECT_NAME} PUBLIC ${PROJECT_NAME}_array ${PROJECT_NAME}_constants + ${PROJECT_NAME}_datetime ${PROJECT_NAME}_core ${PROJECT_NAME}_hash ${PROJECT_NAME}_intrinsics diff --git a/src/datetime/CMakeLists.txt b/src/datetime/CMakeLists.txt new file mode 100644 index 000000000..bebb47da1 --- /dev/null +++ b/src/datetime/CMakeLists.txt @@ -0,0 +1,7 @@ +set(datetime_f90Files + stdlib_datetime.f90 + ) + +configure_stdlib_target(${PROJECT_NAME}_datetime datetime_f90Files "" "") + +target_link_libraries(${PROJECT_NAME}_datetime PUBLIC ${PROJECT_NAME}_core ${PROJECT_NAME}_strings) diff --git a/src/datetime/stdlib_datetime.f90 b/src/datetime/stdlib_datetime.f90 new file mode 100644 index 000000000..c15e03e93 --- /dev/null +++ b/src/datetime/stdlib_datetime.f90 @@ -0,0 +1,765 @@ +module stdlib_datetime + !! version: experimental + !! + !! Date, time, and time interval handling for Fortran. + !! ([Specification](../page/specs/stdlib_datetime.html)) + use stdlib_kinds, only: dp, int64 + use stdlib_strings, only: to_string + implicit none + private + + public :: datetime_type, timedelta_type + public :: datetime, timedelta, now, now_utc, epoch + public :: parse_datetime, format_datetime, format_timedelta + public :: is_leap_year, days_in_month, days_in_year + public :: day_of_year, day_of_week, to_utc, total_seconds + public :: operator(+), operator(-) + public :: operator(==), operator(/=) + public :: operator(<), operator(<=) + public :: operator(>), operator(>=) + + type :: datetime_type + !! version: experimental + !! + !! Represents a specific point in time. + integer :: year = 1 !! Year (1-9999) + integer :: month = 1 !! Month (1-12) + integer :: day = 1 !! Day (1-31) + integer :: hour = 0 !! Hour (0-23) + integer :: minute = 0 !! Minute (0-59) + integer :: second = 0 !! Second (0-59) + integer :: millisecond = 0 !! Millisecond (0-999) + integer :: utc_offset_minutes = 0 !! UTC offset in minutes + end type datetime_type + + type :: timedelta_type + !! version: experimental + !! + !! Represents a duration or time interval. + !! Normalized: seconds in [0,86399], ms in [0,999]. + !! Days can be negative for negative durations. + integer :: days = 0 !! Number of days + integer :: seconds = 0 !! Seconds (0-86399) + integer :: milliseconds = 0 !! Milliseconds (0-999) + end type timedelta_type + + integer(int64), parameter :: MS_PER_SEC = 1000_int64 + integer(int64), parameter :: MS_PER_MIN = 60000_int64 + integer(int64), parameter :: MS_PER_HOUR = 3600000_int64 + integer(int64), parameter :: MS_PER_DAY = 86400000_int64 + + interface operator(+) + module procedure dt_plus_td + module procedure td_plus_dt + module procedure td_plus_td + end interface + + interface operator(-) + module procedure dt_minus_td + module procedure dt_minus_dt + module procedure td_minus_td + module procedure td_negate + end interface + + interface operator(==) + module procedure dt_eq + module procedure td_eq + end interface + + interface operator(/=) + module procedure dt_ne + module procedure td_ne + end interface + + interface operator(<) + module procedure dt_lt + module procedure td_lt + end interface + + interface operator(<=) + module procedure dt_le + module procedure td_le + end interface + + interface operator(>) + module procedure dt_gt + module procedure td_gt + end interface + + interface operator(>=) + module procedure dt_ge + module procedure td_ge + end interface + + interface is_leap_year + module procedure is_leap_year_int + module procedure is_leap_year_dt + end interface + +contains + + pure function datetime(year, month, day, hour, minute, & + second, millisecond, & + utc_offset_minutes) result(dt) + !! version: experimental + !! + !! Create a datetime_type from individual components. + integer, intent(in), optional :: year, month, day + integer, intent(in), optional :: hour, minute, second + integer, intent(in), optional :: millisecond + integer, intent(in), optional :: utc_offset_minutes + type(datetime_type) :: dt + if (present(year)) dt%year = year + if (present(month)) dt%month = month + if (present(day)) dt%day = day + if (present(hour)) dt%hour = hour + if (present(minute)) dt%minute = minute + if (present(second)) dt%second = second + if (present(millisecond)) dt%millisecond = millisecond + if (present(utc_offset_minutes)) & + dt%utc_offset_minutes = utc_offset_minutes + end function datetime + + pure function timedelta(days, hours, minutes, seconds, & + milliseconds) result(td) + !! version: experimental + !! + !! Create a normalized timedelta_type from mixed units. + integer, intent(in), optional :: days, hours, minutes + integer, intent(in), optional :: seconds, milliseconds + type(timedelta_type) :: td + integer(int64) :: total_ms + total_ms = 0_int64 + if (present(days)) & + total_ms = total_ms & + + int(days, int64) * MS_PER_DAY + if (present(hours)) & + total_ms = total_ms & + + int(hours, int64) * MS_PER_HOUR + if (present(minutes)) & + total_ms = total_ms & + + int(minutes, int64) * MS_PER_MIN + if (present(seconds)) & + total_ms = total_ms & + + int(seconds, int64) * MS_PER_SEC + if (present(milliseconds)) & + total_ms = total_ms + int(milliseconds, int64) + td = ms_to_td(total_ms) + end function timedelta + + function now() result(dt) + !! version: experimental + !! + !! Return the current local date and time. + type(datetime_type) :: dt + integer :: v(8) + call date_and_time(values=v) + dt = datetime_type(v(1), v(2), v(3), v(5), & + v(6), v(7), v(8), v(4)) + end function now + + function now_utc() result(dt) + !! version: experimental + !! + !! Return the current UTC date and time. + type(datetime_type) :: dt + dt = to_utc(now()) + end function now_utc + + pure function epoch() result(dt) + !! version: experimental + !! + !! Return the Unix epoch: 1970-01-01T00:00:00Z. + type(datetime_type) :: dt + dt = datetime_type(1970, 1, 1, 0, 0, 0, 0, 0) + end function epoch + + pure function dt_plus_td(dt, td) result(res) + !! datetime + timedelta + type(datetime_type), intent(in) :: dt + type(timedelta_type), intent(in) :: td + type(datetime_type) :: res + res = epoch_ms_to_dt( & + dt_to_epoch_ms(dt) + td_to_ms(td), & + dt%utc_offset_minutes) + end function dt_plus_td + + pure function td_plus_dt(td, dt) result(res) + !! timedelta + datetime (commutative) + type(timedelta_type), intent(in) :: td + type(datetime_type), intent(in) :: dt + type(datetime_type) :: res + res = dt_plus_td(dt, td) + end function td_plus_dt + + pure function td_plus_td(td1, td2) result(res) + !! timedelta + timedelta + type(timedelta_type), intent(in) :: td1, td2 + type(timedelta_type) :: res + res = ms_to_td(td_to_ms(td1) + td_to_ms(td2)) + end function td_plus_td + + pure function dt_minus_td(dt, td) result(res) + !! datetime - timedelta + type(datetime_type), intent(in) :: dt + type(timedelta_type), intent(in) :: td + type(datetime_type) :: res + res = epoch_ms_to_dt( & + dt_to_epoch_ms(dt) - td_to_ms(td), & + dt%utc_offset_minutes) + end function dt_minus_td + + pure function dt_minus_dt(dt1, dt2) result(res) + !! datetime - datetime (both converted to UTC) + type(datetime_type), intent(in) :: dt1, dt2 + type(timedelta_type) :: res + res = ms_to_td(dt_to_utc_ms(dt1) - dt_to_utc_ms(dt2)) + end function dt_minus_dt + + pure function td_minus_td(td1, td2) result(res) + !! timedelta - timedelta + type(timedelta_type), intent(in) :: td1, td2 + type(timedelta_type) :: res + res = ms_to_td(td_to_ms(td1) - td_to_ms(td2)) + end function td_minus_td + + pure function td_negate(td) result(res) + !! Unary minus: -timedelta + type(timedelta_type), intent(in) :: td + type(timedelta_type) :: res + res = ms_to_td(-td_to_ms(td)) + end function td_negate + + pure function dt_eq(dt1, dt2) result(res) + type(datetime_type), intent(in) :: dt1, dt2 + logical :: res + res = dt_to_utc_ms(dt1) == dt_to_utc_ms(dt2) + end function dt_eq + + pure function dt_ne(dt1, dt2) result(res) + type(datetime_type), intent(in) :: dt1, dt2 + logical :: res + res = dt_to_utc_ms(dt1) /= dt_to_utc_ms(dt2) + end function dt_ne + + pure function dt_lt(dt1, dt2) result(res) + type(datetime_type), intent(in) :: dt1, dt2 + logical :: res + res = dt_to_utc_ms(dt1) < dt_to_utc_ms(dt2) + end function dt_lt + + pure function dt_le(dt1, dt2) result(res) + type(datetime_type), intent(in) :: dt1, dt2 + logical :: res + res = dt_to_utc_ms(dt1) <= dt_to_utc_ms(dt2) + end function dt_le + + pure function dt_gt(dt1, dt2) result(res) + type(datetime_type), intent(in) :: dt1, dt2 + logical :: res + res = dt_to_utc_ms(dt1) > dt_to_utc_ms(dt2) + end function dt_gt + + pure function dt_ge(dt1, dt2) result(res) + type(datetime_type), intent(in) :: dt1, dt2 + logical :: res + res = dt_to_utc_ms(dt1) >= dt_to_utc_ms(dt2) + end function dt_ge + + pure function td_eq(td1, td2) result(res) + type(timedelta_type), intent(in) :: td1, td2 + logical :: res + res = td_to_ms(td1) == td_to_ms(td2) + end function td_eq + + pure function td_ne(td1, td2) result(res) + type(timedelta_type), intent(in) :: td1, td2 + logical :: res + res = td_to_ms(td1) /= td_to_ms(td2) + end function td_ne + + pure function td_lt(td1, td2) result(res) + type(timedelta_type), intent(in) :: td1, td2 + logical :: res + res = td_to_ms(td1) < td_to_ms(td2) + end function td_lt + + pure function td_le(td1, td2) result(res) + type(timedelta_type), intent(in) :: td1, td2 + logical :: res + res = td_to_ms(td1) <= td_to_ms(td2) + end function td_le + + pure function td_gt(td1, td2) result(res) + type(timedelta_type), intent(in) :: td1, td2 + logical :: res + res = td_to_ms(td1) > td_to_ms(td2) + end function td_gt + + pure function td_ge(td1, td2) result(res) + type(timedelta_type), intent(in) :: td1, td2 + logical :: res + res = td_to_ms(td1) >= td_to_ms(td2) + end function td_ge + + pure function format_datetime(dt) result(str) + !! version: experimental + !! + !! Format a datetime_type as an ISO 8601 string. + type(datetime_type), intent(in) :: dt + character(:), allocatable :: str + integer :: off_h, off_m + + str = to_string(dt%year, '(I4.4)') // '-' // & + to_string(dt%month, '(I2.2)') // '-' // & + to_string(dt%day, '(I2.2)') // 'T' // & + to_string(dt%hour, '(I2.2)') // ':' // & + to_string(dt%minute, '(I2.2)') // ':' // & + to_string(dt%second, '(I2.2)') + + if (dt%millisecond /= 0) then + str = str // '.' // to_string(dt%millisecond, '(I3.3)') + end if + + if (dt%utc_offset_minutes == 0) then + str = str // 'Z' + else + off_h = abs(dt%utc_offset_minutes) / 60 + off_m = mod(abs(dt%utc_offset_minutes), 60) + if (dt%utc_offset_minutes > 0) then + str = str // '+' + else + str = str // '-' + end if + str = str // to_string(off_h, '(I2.2)') // ':' // & + to_string(off_m, '(I2.2)') + end if + end function format_datetime + + pure function format_timedelta(td) result(str) + !! version: experimental + !! + !! Format a timedelta_type as a readable string. + type(timedelta_type), intent(in) :: td + character(:), allocatable :: str + integer :: h, m, s + + h = td%seconds / 3600 + m = mod(td%seconds, 3600) / 60 + s = mod(td%seconds, 60) + + str = to_string(td%days, '(I0)') // ' days, ' // & + to_string(h, '(I2.2)') // ':' // & + to_string(m, '(I2.2)') // ':' // & + to_string(s, '(I2.2)') + + if (td%milliseconds /= 0) then + str = str // '.' // to_string(td%milliseconds, '(I3.3)') + end if + end function format_timedelta + + function parse_datetime(str, stat) result(dt) + !! version: experimental + !! + !! Parse an ISO 8601 date/time string. + character(len=*), intent(in) :: str + integer, intent(out), optional :: stat + type(datetime_type) :: dt + integer :: slen, ios, off_h, off_m, ms_end + integer :: max_day + character(len=1) :: sign_ch + character(len=32) :: tmp_str + real(dp) :: ms_frac + + if (present(stat)) stat = 0 + dt = datetime_type() + slen = len_trim(str) + + ! Require at least YYYY-MM-DD (10 characters) + if (slen < 10) then + if (present(stat)) stat = 1 + return + end if + + ! Check required date separators for ISO 8601 (YYYY-MM-DD) + if (str(5:5) /= '-' .or. str(8:8) /= '-') then + if (present(stat)) stat = 1 + return + end if + + read(str(1:4), '(I4)', iostat=ios) dt%year + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + read(str(6:7), '(I2)', iostat=ios) dt%month + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + ! Validate month range [1,12] + if (dt%month < 1 .or. dt%month > 12) then + if (present(stat)) stat = 1 + return + end if + read(str(9:10), '(I2)', iostat=ios) dt%day + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + ! Validate day range [1, days_in_month] + max_day = days_in_month(dt%month, dt%year) + if (dt%day < 1 .or. dt%day > max_day) then + if (present(stat)) stat = 1 + return + end if + + if (slen == 10) return + + if (str(11:11) /= 'T' .and. & + str(11:11) /= 't' .and. & + str(11:11) /= ' ') then + if (present(stat)) stat = 1 + return + end if + + if (slen < 19) then + if (present(stat)) stat = 1 + return + end if + + ! Validate required time separators (HH:MM:SS) + if (str(14:14) /= ':' .or. str(17:17) /= ':') then + if (present(stat)) stat = 1 + return + end if + + read(str(12:13), '(I2)', iostat=ios) dt%hour + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + ! Validate hour range [0,23] + if (dt%hour < 0 .or. dt%hour > 23) then + if (present(stat)) stat = 1 + return + end if + read(str(15:16), '(I2)', iostat=ios) dt%minute + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + ! Validate minute range [0,59] + if (dt%minute < 0 .or. dt%minute > 59) then + if (present(stat)) stat = 1 + return + end if + read(str(18:19), '(I2)', iostat=ios) dt%second + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + ! Validate second range [0,59] + if (dt%second < 0 .or. dt%second > 59) then + if (present(stat)) stat = 1 + return + end if + + if (slen == 19) return + + ms_end = 19 + if (str(20:20) == '.') then + ms_end = 20 + do while (ms_end < slen) + sign_ch = str(ms_end+1:ms_end+1) + if (sign_ch >= '0' .and. sign_ch <= '9') then + ms_end = ms_end + 1 + else + exit + end if + end do + if (ms_end == 20) then + ! "." without following digits + if (present(stat)) stat = 1 + return + end if + tmp_str = '0' // str(20:ms_end) + read(tmp_str, *, iostat=ios) ms_frac + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + dt%millisecond = nint(ms_frac * 1000.0_dp) + end if + + if (slen <= ms_end) return + + sign_ch = str(ms_end+1:ms_end+1) + if (sign_ch == 'Z' .or. sign_ch == 'z') then + dt%utc_offset_minutes = 0 + else if (sign_ch == '+' .or. sign_ch == '-') then + if (slen < ms_end + 6) then + if (present(stat)) stat = 1 + return + end if + read(str(ms_end+2:ms_end+3), '(I2)', & + iostat=ios) off_h + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + ! Require ':' between offset hours and minutes + if (str(ms_end+4:ms_end+4) /= ':') then + if (present(stat)) stat = 1 + return + end if + read(str(ms_end+5:ms_end+6), '(I2)', & + iostat=ios) off_m + if (ios /= 0) then + if (present(stat)) stat = 1 + return + end if + ! Validate timezone offset ranges + if (off_h < 0 .or. off_h > 23 .or. & + off_m < 0 .or. off_m > 59) then + if (present(stat)) stat = 1 + return + end if + dt%utc_offset_minutes = off_h * 60 + off_m + if (sign_ch == '-') & + dt%utc_offset_minutes = & + -dt%utc_offset_minutes + else + if (present(stat)) stat = 1 + return + end if + end function parse_datetime + + pure elemental function is_leap_year_int(year) & + result(res) + !! version: experimental + !! + !! Check if a year is a leap year. + integer, intent(in) :: year + logical :: res + res = (mod(year, 4) == 0 & + .and. mod(year, 100) /= 0) & + .or. (mod(year, 400) == 0) + end function is_leap_year_int + + pure elemental function is_leap_year_dt(dt) & + result(res) + !! version: experimental + !! + !! Check if a datetime's year is a leap year. + type(datetime_type), intent(in) :: dt + logical :: res + res = is_leap_year_int(dt%year) + end function is_leap_year_dt + + pure function days_in_month(month, year) result(d) + !! version: experimental + !! + !! Return the number of days in a given month. + integer, intent(in) :: month, year + integer :: d + integer, parameter :: mdays(12) = & + [31,28,31,30,31,30,31,31,30,31,30,31] + if (month < 1 .or. month > 12) then + d = 0 + return + end if + d = mdays(month) + if (month == 2 .and. is_leap_year_int(year)) & + d = 29 + end function days_in_month + + pure function days_in_year(year) result(d) + !! version: experimental + !! + !! Return 366 for leap years, 365 otherwise. + integer, intent(in) :: year + integer :: d + d = merge(366, 365, is_leap_year_int(year)) + end function days_in_year + + pure function day_of_year(dt) result(doy) + !! version: experimental + !! + !! Return the ordinal day of the year (1-366). + type(datetime_type), intent(in) :: dt + integer :: doy + integer, parameter :: cum(12) = & + [0,31,59,90,120,151,181,212,243,273,304,334] + ! Guard against invalid month values + if (dt%month < 1 .or. dt%month > 12) then + doy = 0 + return + end if + doy = cum(dt%month) + dt%day + if (dt%month > 2 .and. is_leap_year_int(dt%year))& + doy = doy + 1 + end function day_of_year + + pure function day_of_week(dt) result(dow) + !! version: experimental + !! + !! Return ISO weekday (1=Monday ... 7=Sunday). + type(datetime_type), intent(in) :: dt + integer :: dow + integer :: y, w + integer, parameter :: t(12) = & + [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4] + ! Guard against invalid month values + if (dt%month < 1 .or. dt%month > 12) then + dow = 0 + return + end if + y = dt%year + if (dt%month < 3) y = y - 1 + w = mod(y + y/4 - y/100 + y/400 & + + t(dt%month) + dt%day, 7) + dow = mod(w + 6, 7) + 1 + end function day_of_week + + pure function to_utc(dt) result(utc_dt) + !! version: experimental + !! + !! Convert a datetime to UTC. + type(datetime_type), intent(in) :: dt + type(datetime_type) :: utc_dt + utc_dt = epoch_ms_to_dt(dt_to_utc_ms(dt), 0) + end function to_utc + + pure function total_seconds(td) result(secs) + !! version: experimental + !! + !! Return the total duration in seconds as real(dp). + type(timedelta_type), intent(in) :: td + real(dp) :: secs + secs = real(td%days, dp) * 86400.0_dp & + + real(td%seconds, dp) & + + real(td%milliseconds, dp) * 0.001_dp + end function total_seconds + + pure function days_from_civil(y, m, d) result(days) + !! Convert civil date to days since 1970-01-01. + !! Howard Hinnant's algorithm (public domain). + integer, intent(in) :: y, m, d + integer(int64) :: days + integer :: yr, era, yoe, doy, doe, mp + yr = y + if (m <= 2) yr = yr - 1 + if (yr >= 0) then + era = yr / 400 + else + era = (yr - 399) / 400 + end if + yoe = yr - era * 400 + if (m > 2) then + mp = m - 3 + else + mp = m + 9 + end if + doy = (153 * mp + 2) / 5 + d - 1 + doe = yoe * 365 + yoe/4 - yoe/100 + doy + days = int(era, int64) * 146097_int64 & + + int(doe, int64) - 719468_int64 + end function days_from_civil + + pure subroutine civil_from_days(z, y, m, d) + !! Convert days since 1970-01-01 to civil date. + !! Howard Hinnant's algorithm (public domain). + integer(int64), intent(in) :: z + integer, intent(out) :: y, m, d + integer(int64) :: zz, era64 + integer :: doe, yoe, doy, mp, era + zz = z + 719468_int64 + if (zz >= 0) then + era64 = zz / 146097_int64 + else + era64 = (zz - 146096_int64) / 146097_int64 + end if + era = int(era64) + doe = int(zz - era64 * 146097_int64) + yoe = (doe - doe/1461 + doe/36524 & + - doe/146096) / 365 + y = yoe + era * 400 + doy = doe - (365*yoe + yoe/4 - yoe/100) + mp = (5*doy + 2) / 153 + d = doy - (153*mp + 2)/5 + 1 + if (mp < 10) then + m = mp + 3 + else + m = mp - 9 + end if + if (m <= 2) y = y + 1 + end subroutine civil_from_days + + pure function dt_to_epoch_ms(dt) result(ms) + !! Datetime to milliseconds since epoch (local). + type(datetime_type), intent(in) :: dt + integer(int64) :: ms + ms = days_from_civil(dt%year, dt%month, dt%day) & + * MS_PER_DAY & + + int(dt%hour, int64) * MS_PER_HOUR & + + int(dt%minute, int64) * MS_PER_MIN & + + int(dt%second, int64) * MS_PER_SEC & + + int(dt%millisecond, int64) + end function dt_to_epoch_ms + + pure function dt_to_utc_ms(dt) result(ms) + !! Datetime to UTC milliseconds since epoch. + type(datetime_type), intent(in) :: dt + integer(int64) :: ms + ms = dt_to_epoch_ms(dt) & + - int(dt%utc_offset_minutes, int64) * MS_PER_MIN + end function dt_to_utc_ms + + pure function epoch_ms_to_dt(ms, utc_offset) result(dt) + !! Milliseconds since epoch to datetime. + integer(int64), intent(in) :: ms + integer, intent(in) :: utc_offset + type(datetime_type) :: dt + integer(int64) :: d, rem + d = ms / MS_PER_DAY + rem = ms - d * MS_PER_DAY + if (rem < 0) then + d = d - 1_int64 + rem = rem + MS_PER_DAY + end if + call civil_from_days(d, dt%year, dt%month, dt%day) + dt%hour = int(rem / MS_PER_HOUR) + rem = mod(rem, MS_PER_HOUR) + dt%minute = int(rem / MS_PER_MIN) + rem = mod(rem, MS_PER_MIN) + dt%second = int(rem / MS_PER_SEC) + dt%millisecond = int(mod(rem, MS_PER_SEC)) + dt%utc_offset_minutes = utc_offset + end function epoch_ms_to_dt + + pure function td_to_ms(td) result(ms) + !! Timedelta to total milliseconds. + type(timedelta_type), intent(in) :: td + integer(int64) :: ms + ms = int(td%days, int64) * MS_PER_DAY & + + int(td%seconds, int64) * MS_PER_SEC & + + int(td%milliseconds, int64) + end function td_to_ms + + pure function ms_to_td(ms) result(td) + !! Total milliseconds to normalized timedelta. + integer(int64), intent(in) :: ms + type(timedelta_type) :: td + integer(int64) :: rem + td%days = int(ms / MS_PER_DAY) + rem = ms - int(td%days, int64) * MS_PER_DAY + if (rem < 0) then + td%days = td%days - 1 + rem = rem + MS_PER_DAY + end if + td%seconds = int(rem / MS_PER_SEC) + td%milliseconds = int(mod(rem, MS_PER_SEC)) + end function ms_to_td + +end module stdlib_datetime diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index ea43bc8eb..d90af3fa1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -33,6 +33,7 @@ if (STDLIB_BITSETS) add_subdirectory(bitsets) endif() add_subdirectory(constants) +add_subdirectory(datetime) add_subdirectory(hash_functions) add_subdirectory(hash_functions_perf) if (STDLIB_HASHMAPS) diff --git a/test/datetime/CMakeLists.txt b/test/datetime/CMakeLists.txt new file mode 100644 index 000000000..9e0d9583a --- /dev/null +++ b/test/datetime/CMakeLists.txt @@ -0,0 +1 @@ +ADDTEST(datetime) diff --git a/test/datetime/test_datetime.f90 b/test/datetime/test_datetime.f90 new file mode 100644 index 000000000..e22344c38 --- /dev/null +++ b/test/datetime/test_datetime.f90 @@ -0,0 +1,669 @@ +module test_datetime + use testdrive, only: new_unittest, unittest_type, & + error_type, check + use stdlib_datetime + implicit none + private + public :: collect_datetime + +contains + +subroutine collect_datetime(testsuite) + !> Collection of tests + type(unittest_type), allocatable, intent(out) :: & + testsuite(:) + testsuite = [ & + new_unittest("leap_year_basic", & + test_leap_year_basic), & + new_unittest("leap_year_century", & + test_leap_year_century), & + new_unittest("days_in_month_basic", & + test_days_in_month_basic), & + new_unittest("days_in_month_feb_leap", & + test_days_in_month_feb_leap), & + new_unittest("days_in_year_test", & + test_days_in_year), & + new_unittest("day_of_year_test", & + test_day_of_year), & + new_unittest("day_of_week_known", & + test_day_of_week_known), & + new_unittest("constructor_datetime", & + test_constructor_datetime), & + new_unittest("constructor_timedelta", & + test_constructor_timedelta), & + new_unittest("timedelta_normalization", & + test_timedelta_normalization), & + new_unittest("epoch_value", & + test_epoch_value), & + new_unittest("dt_plus_td", & + test_dt_plus_td), & + new_unittest("dt_minus_td", & + test_dt_minus_td), & + new_unittest("dt_minus_dt", & + test_dt_minus_dt), & + new_unittest("td_plus_td", & + test_td_plus_td), & + new_unittest("td_minus_td", & + test_td_minus_td), & + new_unittest("td_negate", & + test_td_negate_op), & + new_unittest("dt_comparison_eq", & + test_dt_eq), & + new_unittest("dt_comparison_lt_gt", & + test_dt_lt_gt), & + new_unittest("dt_comparison_timezone", & + test_dt_cmp_timezone), & + new_unittest("td_comparison", & + test_td_comparison), & + new_unittest("parse_date_only", & + test_parse_date_only), & + new_unittest("parse_datetime_utc", & + test_parse_datetime_utc), & + new_unittest("parse_datetime_offset", & + test_parse_datetime_offset), & + new_unittest("parse_datetime_ms", & + test_parse_datetime_ms), & + new_unittest("parse_invalid", & + test_parse_invalid), & + new_unittest("format_datetime_utc", & + test_format_datetime_utc), & + new_unittest("format_datetime_offset", & + test_format_datetime_offset), & + new_unittest("format_timedelta_test", & + test_format_timedelta), & + new_unittest("timedelta_ms_rollover", & + test_timedelta_ms_rollover), & + new_unittest("to_utc_test", & + test_to_utc), & + new_unittest("total_seconds_test", & + test_total_seconds), & + new_unittest("add_30_days", & + test_add_30_days), & + new_unittest("midnight_rollover", & + test_midnight_rollover), & + new_unittest("year_boundary", & + test_year_boundary), & + new_unittest("now_returns_valid", & + test_now_valid)] +end subroutine collect_datetime + +subroutine test_leap_year_basic(error) + type(error_type), allocatable, intent(out) :: error + call check(error, is_leap_year(2000), & + "2000 should be a leap year") + if (allocated(error)) return + call check(error, is_leap_year(2024), & + "2024 should be a leap year") + if (allocated(error)) return + call check(error, .not. is_leap_year(2023), & + "2023 should not be a leap year") + if (allocated(error)) return + call check(error, .not. is_leap_year(2025), & + "2025 should not be a leap year") + if (allocated(error)) return +end subroutine test_leap_year_basic + +subroutine test_leap_year_century(error) + type(error_type), allocatable, intent(out) :: error + call check(error, .not. is_leap_year(1900), & + "1900 should not be a leap year") + if (allocated(error)) return + call check(error, .not. is_leap_year(2100), & + "2100 should not be a leap year") + if (allocated(error)) return + call check(error, is_leap_year(2400), & + "2400 should be a leap year") + if (allocated(error)) return +end subroutine test_leap_year_century + +subroutine test_days_in_month_basic(error) + type(error_type), allocatable, intent(out) :: error + call check(error, days_in_month(1, 2026) == 31, & + "January 2026 should have 31 days") + if (allocated(error)) return + call check(error, days_in_month(4, 2026) == 30, & + "April 2026 should have 30 days") + if (allocated(error)) return + call check(error, days_in_month(2, 2026) == 28, & + "February 2026 should have 28 days") + if (allocated(error)) return +end subroutine test_days_in_month_basic + +subroutine test_days_in_month_feb_leap(error) + type(error_type), allocatable, intent(out) :: error + call check(error, days_in_month(2, 2024) == 29, & + "February 2024 (leap) should have 29 days") + if (allocated(error)) return + call check(error, days_in_month(2, 2000) == 29, & + "February 2000 (leap) should have 29 days") + if (allocated(error)) return + call check(error, days_in_month(2, 1900) == 28, & + "February 1900 (non-leap) should have 28 days") + if (allocated(error)) return +end subroutine test_days_in_month_feb_leap + +subroutine test_days_in_year(error) + type(error_type), allocatable, intent(out) :: error + call check(error, days_in_year(2024) == 366, & + "2024 (leap) should have 366 days") + if (allocated(error)) return + call check(error, days_in_year(2026) == 365, & + "2026 (non-leap) should have 365 days") + if (allocated(error)) return +end subroutine test_days_in_year + +subroutine test_day_of_year(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + dt = datetime_type(2026, 1, 1, 0, 0, 0, 0, 0) + call check(error, day_of_year(dt) == 1, & + "Jan 1 should be day 1 of year") + if (allocated(error)) return + dt = datetime_type(2026, 3, 17, 0, 0, 0, 0, 0) + call check(error, day_of_year(dt) == 76, & + "Mar 17, 2026 should be day 76") + if (allocated(error)) return + dt = datetime_type(2024, 12, 31, 0, 0, 0, 0, 0) + call check(error, day_of_year(dt) == 366, & + "Dec 31, 2024 (leap) should be day 366") + if (allocated(error)) return +end subroutine test_day_of_year + +subroutine test_day_of_week_known(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + dt = datetime_type(2026, 3, 17, 0, 0, 0, 0, 0) + call check(error, day_of_week(dt) == 2, & + "2026-03-17 should be Tuesday (2)") + if (allocated(error)) return + dt = datetime_type(1970, 1, 1, 0, 0, 0, 0, 0) + call check(error, day_of_week(dt) == 4, & + "1970-01-01 should be Thursday (4)") + if (allocated(error)) return + dt = datetime_type(2000, 1, 1, 0, 0, 0, 0, 0) + call check(error, day_of_week(dt) == 6, & + "2000-01-01 should be Saturday (6)") + if (allocated(error)) return +end subroutine test_day_of_week_known + +subroutine test_constructor_datetime(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + dt = datetime(year=2026, month=3, day=17, & + hour=12, minute=30, second=45) + call check(error, dt%year == 2026, & + "datetime year should be 2026") + if (allocated(error)) return + call check(error, dt%month == 3, & + "datetime month should be 3") + if (allocated(error)) return + call check(error, dt%day == 17, & + "datetime day should be 17") + if (allocated(error)) return + call check(error, dt%hour == 12, & + "datetime hour should be 12") + if (allocated(error)) return + call check(error, dt%minute == 30, & + "datetime minute should be 30") + if (allocated(error)) return + call check(error, dt%second == 45, & + "datetime second should be 45") + if (allocated(error)) return +end subroutine test_constructor_datetime + +subroutine test_constructor_timedelta(error) + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td + td = timedelta(days=5, hours=3, minutes=30) + call check(error, td%days == 5, & + "timedelta days should be 5") + if (allocated(error)) return + call check(error, td%seconds == 12600, & + "timedelta seconds should be 12600") + if (allocated(error)) return +end subroutine test_constructor_timedelta + +subroutine test_timedelta_normalization(error) + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td + td = timedelta(hours=25) + call check(error, td%days == 1, & + "25 hours should normalize to 1 day") + if (allocated(error)) return + call check(error, td%seconds == 3600, & + "25 hours should normalize to 3600 remaining seconds") + if (allocated(error)) return + td = timedelta(seconds=-1) + call check(error, td%days == -1, & + "-1 second should normalize to -1 day") + if (allocated(error)) return + call check(error, td%seconds == 86399, & + "-1 second should normalize to 86399 seconds") + if (allocated(error)) return +end subroutine test_timedelta_normalization + +subroutine test_epoch_value(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + dt = epoch() + call check(error, dt%year == 1970, & + "epoch year should be 1970") + if (allocated(error)) return + call check(error, dt%month == 1, & + "epoch month should be 1") + if (allocated(error)) return + call check(error, dt%day == 1, & + "epoch day should be 1") + if (allocated(error)) return +end subroutine test_epoch_value + +subroutine test_dt_plus_td(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt, res + type(timedelta_type) :: td + dt = datetime_type(2026, 3, 17, 12, 0, 0, 0, 0) + td = timedelta(days=1, hours=6) + res = dt + td + call check(error, res%day == 18, & + "dt + td day should be 18") + if (allocated(error)) return + call check(error, res%hour == 18, & + "dt + td hour should be 18") + if (allocated(error)) return + res = td + dt + call check(error, res%day == 18, & + "td + dt day should be 18 (commutative)") + if (allocated(error)) return +end subroutine test_dt_plus_td + +subroutine test_dt_minus_td(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt, res + type(timedelta_type) :: td + dt = datetime_type(2026, 3, 17, 12, 0, 0, 0, 0) + td = timedelta(days=17) + res = dt - td + call check(error, res%month == 2, & + "dt - 17 days month should be February") + if (allocated(error)) return + call check(error, res%day == 28, & + "dt - 17 days day should be 28") + if (allocated(error)) return +end subroutine test_dt_minus_td + +subroutine test_dt_minus_dt(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt1, dt2 + type(timedelta_type) :: td + dt1 = datetime_type(2026, 3, 17, 0, 0, 0, 0, 0) + dt2 = datetime_type(2026, 3, 10, 0, 0, 0, 0, 0) + td = dt1 - dt2 + call check(error, td%days == 7, & + "difference should be 7 days") + if (allocated(error)) return + call check(error, td%seconds == 0, & + "difference seconds should be 0") + if (allocated(error)) return +end subroutine test_dt_minus_dt + +subroutine test_td_plus_td(error) + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td1, td2, res + td1 = timedelta(days=1, hours=12) + td2 = timedelta(hours=18) + res = td1 + td2 + call check(error, res%days == 2, & + "td1 + td2 days should be 2") + if (allocated(error)) return + call check(error, res%seconds == 21600, & + "td1 + td2 seconds should be 21600") + if (allocated(error)) return +end subroutine test_td_plus_td + +subroutine test_td_minus_td(error) + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td1, td2, res + td1 = timedelta(days=5) + td2 = timedelta(days=3) + res = td1 - td2 + call check(error, res%days == 2, & + "td1 - td2 days should be 2") + if (allocated(error)) return +end subroutine test_td_minus_td + +subroutine test_td_negate_op(error) + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td, res + td = timedelta(days=3, hours=6) + res = -td + call check(error, res%days == -4, & + "negated days should be -4") + if (allocated(error)) return + call check(error, res%seconds == 64800, & + "negated seconds should be 64800") + if (allocated(error)) return +end subroutine test_td_negate_op + +subroutine test_dt_eq(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt1, dt2 + dt1 = datetime_type(2026, 3, 17, 12, 0, 0, 0, 0) + dt2 = datetime_type(2026, 3, 17, 12, 0, 0, 0, 0) + call check(error, dt1 == dt2, & + "identical datetimes should be equal") + if (allocated(error)) return + call check(error, .not. (dt1 /= dt2), & + "identical datetimes should not be unequal") + if (allocated(error)) return +end subroutine test_dt_eq + +subroutine test_dt_lt_gt(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt1, dt2 + dt1 = datetime_type(2026, 3, 17, 11, 0, 0, 0, 0) + dt2 = datetime_type(2026, 3, 17, 12, 0, 0, 0, 0) + call check(error, dt1 < dt2, & + "earlier datetime should be less than later") + if (allocated(error)) return + call check(error, dt2 > dt1, & + "later datetime should be greater than earlier") + if (allocated(error)) return + call check(error, dt1 <= dt2, & + "earlier datetime should be <= later") + if (allocated(error)) return + call check(error, dt2 >= dt1, & + "later datetime should be >= earlier") + if (allocated(error)) return +end subroutine test_dt_lt_gt + +subroutine test_dt_cmp_timezone(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt1, dt2 + dt1 = datetime_type(2026, 3, 17, 12, 0, 0, 0, 0) + dt2 = datetime_type(2026,3,17, 17,30, 0, 0, 330) + call check(error, dt1 == dt2, & + "UTC 12:00 should equal +05:30 17:30") + if (allocated(error)) return +end subroutine test_dt_cmp_timezone + +subroutine test_td_comparison(error) + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td1, td2 + td1 = timedelta(days=1) + td2 = timedelta(hours=25) + call check(error, td2 > td1, & + "25 hours should be greater than 1 day") + if (allocated(error)) return + td2 = timedelta(hours=24) + call check(error, td1 == td2, & + "1 day should equal 24 hours") + if (allocated(error)) return +end subroutine test_td_comparison + +subroutine test_parse_date_only(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + integer :: stat + dt = parse_datetime('2026-03-17', stat) + call check(error, stat == 0, & + "parsing '2026-03-17' should succeed") + if (allocated(error)) return + call check(error, dt%year == 2026, & + "parsed year should be 2026") + if (allocated(error)) return + call check(error, dt%month == 3, & + "parsed month should be 3") + if (allocated(error)) return + call check(error, dt%day == 17, & + "parsed day should be 17") + if (allocated(error)) return +end subroutine test_parse_date_only + +subroutine test_parse_datetime_utc(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + integer :: stat + dt = parse_datetime('2026-03-17T12:00:00Z', stat) + call check(error, stat == 0, & + "parsing UTC datetime should succeed") + if (allocated(error)) return + call check(error, dt%year == 2026, & + "parsed year should be 2026") + if (allocated(error)) return + call check(error, dt%hour == 12, & + "parsed hour should be 12") + if (allocated(error)) return + call check(error, dt%utc_offset_minutes == 0, & + "parsed UTC offset should be 0") + if (allocated(error)) return +end subroutine test_parse_datetime_utc + +subroutine test_parse_datetime_offset(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + integer :: stat + dt = parse_datetime( & + '2026-03-17T23:05:15+05:30', stat) + call check(error, stat == 0, & + "parsing offset datetime should succeed") + if (allocated(error)) return + call check(error, dt%hour == 23, & + "parsed hour should be 23") + if (allocated(error)) return + call check(error, dt%minute == 5, & + "parsed minute should be 5") + if (allocated(error)) return + call check(error, dt%utc_offset_minutes == 330, & + "parsed UTC offset should be 330 minutes") + if (allocated(error)) return +end subroutine test_parse_datetime_offset + +subroutine test_parse_datetime_ms(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + integer :: stat + dt = parse_datetime( & + '2026-03-17T12:00:00.500Z', stat) + call check(error, stat == 0, & + "parsing datetime with milliseconds should succeed") + if (allocated(error)) return + call check(error, dt%millisecond == 500, & + "parsed millisecond should be 500") + if (allocated(error)) return +end subroutine test_parse_datetime_ms + +subroutine test_parse_invalid(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + integer :: stat + dt = parse_datetime('bad', stat) + call check(error, stat /= 0, & + "parsing 'bad' should fail with non-zero stat") + if (allocated(error)) return +end subroutine test_parse_invalid + +subroutine test_format_datetime_utc(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + dt = datetime_type(2026, 3, 17, 12, 0, 0, 0, 0) + call check(error, & + format_datetime(dt) == '2026-03-17T12:00:00Z', & + "UTC format should be '2026-03-17T12:00:00Z'") + if (allocated(error)) return +end subroutine test_format_datetime_utc + +subroutine test_format_datetime_offset(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + dt = datetime_type(2026,3,17, 23,5,15, 0, 330) + call check(error, & + format_datetime(dt) == '2026-03-17T23:05:15+05:30', & + "offset format should be '2026-03-17T23:05:15+05:30'") + if (allocated(error)) return +end subroutine test_format_datetime_offset + +subroutine test_format_timedelta(error) + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td + 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'") + if (allocated(error)) return +end subroutine test_format_timedelta + +subroutine test_timedelta_ms_rollover(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt, res + dt = datetime_type(1970, 1, 1, 0, 0, 0, 0, 0) + res = dt + timedelta(days=0) + call check(error, res%year == 1970, & + "epoch + 0 days year should be 1970") + if (allocated(error)) return + call check(error, res%month == 1, & + "epoch + 0 days month should be 1") + if (allocated(error)) return + call check(error, res%day == 1, & + "epoch + 0 days day should be 1") + if (allocated(error)) return + dt = datetime_type(2000, 2, 29, 23,59,59, 999, 0) + res = dt + timedelta(milliseconds=1) + call check(error, res%month == 3, & + "leap day ms rollover month should be 3") + if (allocated(error)) return + call check(error, res%day == 1, & + "leap day ms rollover day should be 1") + if (allocated(error)) return + call check(error, res%hour == 0, & + "leap day ms rollover hour should be 0") + if (allocated(error)) return + call check(error, res%second == 0, & + "leap day ms rollover second should be 0") + if (allocated(error)) return +end subroutine test_timedelta_ms_rollover + +subroutine test_to_utc(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt, utc + dt = datetime_type(2026,3,17, 23,5,15, 0, 330) + utc = to_utc(dt) + call check(error, utc%hour == 17, & + "to_utc hour should be 17") + if (allocated(error)) return + call check(error, utc%minute == 35, & + "to_utc minute should be 35") + if (allocated(error)) return + call check(error, utc%utc_offset_minutes == 0, & + "to_utc offset should be 0") + if (allocated(error)) return +end subroutine test_to_utc + +subroutine test_total_seconds(error) + use stdlib_kinds, only: dp + type(error_type), allocatable, intent(out) :: error + type(timedelta_type) :: td + td = timedelta(days=1, hours=1) + call check(error, & + abs(total_seconds(td) - 90000.0_dp) & + < 0.001_dp, & + "total_seconds of 1 day 1 hour should be 90000") + if (allocated(error)) return +end subroutine test_total_seconds + +subroutine test_add_30_days(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt, res + dt = datetime_type(2026, 1, 15, 0, 0, 0, 0, 0) + res = dt + timedelta_type(30, 0, 0) + call check(error, res%month == 2, & + "Jan 15 + 30 days month should be February") + if (allocated(error)) return + call check(error, res%day == 14, & + "Jan 15 + 30 days day should be 14") + if (allocated(error)) return +end subroutine test_add_30_days + +subroutine test_midnight_rollover(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt, res + dt = datetime_type(2026,3,17, 23,59,59, 0, 0) + res = dt + timedelta(seconds=1) + call check(error, res%day == 18, & + "midnight rollover day should be 18") + if (allocated(error)) return + call check(error, res%hour == 0, & + "midnight rollover hour should be 0") + if (allocated(error)) return + call check(error, res%minute == 0, & + "midnight rollover minute should be 0") + if (allocated(error)) return + call check(error, res%second == 0, & + "midnight rollover second should be 0") + if (allocated(error)) return +end subroutine test_midnight_rollover + +subroutine test_year_boundary(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt, res + dt = datetime_type(2025,12,31, 23,59,59, 0, 0) + res = dt + timedelta(seconds=1) + call check(error, res%year == 2026, & + "year boundary rollover year should be 2026") + if (allocated(error)) return + call check(error, res%month == 1, & + "year boundary rollover month should be 1") + if (allocated(error)) return + call check(error, res%day == 1, & + "year boundary rollover day should be 1") + if (allocated(error)) return +end subroutine test_year_boundary + +subroutine test_now_valid(error) + type(error_type), allocatable, intent(out) :: error + type(datetime_type) :: dt + dt = now() + call check(error, & + dt%year >= 1 .and. dt%year <= 9999, & + "now() year should be in valid range") + if (allocated(error)) return + call check(error, & + dt%month >= 1 .and. dt%month <= 12, & + "now() month should be in valid range") + if (allocated(error)) return + call check(error, & + dt%day >= 1 .and. dt%day <= 31, & + "now() day should be in valid range") + if (allocated(error)) return +end subroutine test_now_valid + +end module test_datetime + + +program tester + use iso_fortran_env + use testdrive, only: run_testsuite, new_testsuite, & + testsuite_type + use test_datetime, only: collect_datetime + implicit none + type(testsuite_type), allocatable :: testsuites(:) + character(len=*), parameter :: fmt = '("#", *(1x, a))' + integer :: stat, is + + stat = 0 + testsuites = [ & + new_testsuite("datetime", collect_datetime)] + + do is = 1, size(testsuites) + write(error_unit, fmt) & + "Testing:", testsuites(is)%name + call run_testsuite(testsuites(is)%collect, & + error_unit, stat) + end do + + if (stat > 0) then + write(error_unit, '(i0, 1x, a)') stat, & + "test(s) failed!" + error stop + end if + +end program tester