From 534d12a4a6eb28e574ca6a8ac7ed824a80728dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Fri, 1 May 2026 05:42:20 -0600 Subject: [PATCH] feat: parse ISO 8601 ordinal dates (YYYY-DDD) Add support for ISO 8601 ordinal date format where DDD is the day-of-year (001-366). Examples: "2025-123" = May 3, 2025. This format is used in scientific data, satellite tracking, military contexts, and some log formats. Supports optional time suffix (e.g., "2025-123T10:30:00Z") and validates day-of-year range including leap year awareness. Co-Authored-By: Claude Opus 4.6 --- lib/Date/Parse.pm | 24 ++++++++++ t/iso-ordinal-date.t | 107 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 t/iso-ordinal-date.t diff --git a/lib/Date/Parse.pm b/lib/Date/Parse.pm index fd6cef2..58ef600 100644 --- a/lib/Date/Parse.pm +++ b/lib/Date/Parse.pm @@ -111,6 +111,28 @@ sub { return; # invalid week or day number } } + # ISO 8601 ordinal date: YYYY-DDD (with optional time suffix) + # Must be matched before ISO compact to prevent "2025-123" from being mishandled. + # DDD is the day-of-year (001-366). Delimiter required to avoid false positives + # with 7-digit numbers. + elsif ($dtstr =~ s/\s(\d{4})-(\d{3})(?:[-Tt ](\d\d?)(?:([-:]?)(\d\d?)(?:\4(\d\d?)(?:[.,](\d+))?)?)?)?(?=\D)/ /) { + my($ord_y,$ord_d) = ($1,$2); + ($hh,$mm,$ss,$frac) = ($3,$5,$6,$7); + if ($ord_d >= 1 && $ord_d <= 366) { + # Convert ordinal day to calendar month/day. + my $jan1_t = timegm(0, 0, 12, 1, 0, $ord_y); + my @cal = gmtime($jan1_t + ($ord_d - 1) * 86400); + # Verify the resulting date is still in the same year (rejects day 366 + # for non-leap years and day > 365 for leap years past Dec 31). + if ($cal[5] + 1900 != $ord_y) { + return; # day-of-year out of range for this year + } + ($year,$month,$day) = ($ord_y, $cal[4], $cal[3]); + } + else { + return; # invalid day-of-year + } + } # ISO compact: YYYYMMDD without delimiter (month and day must be exactly 2 digits) elsif ($dtstr =~ s/\s(\d{4})(\d\d)(\d\d)(?:[-Tt ](\d\d?)(?:([-:]?)(\d\d?)(?:\5(\d\d?)(?:[.,](\d+))?)?)?)?(?=\D)/ /) { ($year,$month,$day,$hh,$mm,$ss,$frac) = ($1,$2-1,$3,$4,$6,$7,$8); @@ -460,6 +482,8 @@ English, French, German and Italian. Below is a sample list of dates that are known to be parsable with Date::Parse 1995-01-24T09:08:17.1823213 ISO-8601 + 2025-123 ISO-8601 ordinal date (day 123 of 2025) + 2025-123T10:30:00Z ISO-8601 ordinal date with time 2025-W17-1 ISO-8601 week date (Monday of week 17) 2025-W17-1T10:30:00Z ISO-8601 week date with time Wed, 16 Jun 94 07:29:35 CST Comma and day name are optional diff --git a/t/iso-ordinal-date.t b/t/iso-ordinal-date.t new file mode 100644 index 0000000..cfc338c --- /dev/null +++ b/t/iso-ordinal-date.t @@ -0,0 +1,107 @@ +use strict; +use warnings; +use Test::More; +use Date::Parse; +use POSIX qw(strftime); +use Time::Local; + +# ISO 8601 ordinal date format: YYYY-DDD +# DDD is the day-of-year (001-366) + +# Helper: compute expected epoch for a given ordinal date +sub ordinal_epoch { + my ($year, $doy) = @_; + my $jan1 = timegm(0, 0, 0, 1, 0, $year); + return $jan1 + ($doy - 1) * 86400; +} + +# Basic ordinal dates +{ + # 2025-001 = January 1, 2025 + my $t = str2time("2025-001", "GMT"); + is($t, ordinal_epoch(2025, 1), "2025-001 = Jan 1 2025"); + + # 2025-032 = February 1, 2025 + $t = str2time("2025-032", "GMT"); + is($t, ordinal_epoch(2025, 32), "2025-032 = Feb 1 2025"); + + # 2025-123 = May 3, 2025 + $t = str2time("2025-123", "GMT"); + is($t, ordinal_epoch(2025, 123), "2025-123 = May 3 2025"); + is(strftime("%Y-%m-%d", gmtime($t)), "2025-05-03", "2025-123 formats as 2025-05-03"); + + # 2025-365 = December 31, 2025 (non-leap year) + $t = str2time("2025-365", "GMT"); + is($t, ordinal_epoch(2025, 365), "2025-365 = Dec 31 2025"); + is(strftime("%Y-%m-%d", gmtime($t)), "2025-12-31", "2025-365 formats as 2025-12-31"); +} + +# Leap year handling +{ + # 2024 is a leap year (366 days) + my $t = str2time("2024-366", "GMT"); + is($t, ordinal_epoch(2024, 366), "2024-366 = Dec 31 2024 (leap year)"); + is(strftime("%Y-%m-%d", gmtime($t)), "2024-12-31", "2024-366 formats as 2024-12-31"); + + # Day 60 in leap year = Feb 29 + $t = str2time("2024-060", "GMT"); + is(strftime("%Y-%m-%d", gmtime($t)), "2024-02-29", "2024-060 = Feb 29 (leap year)"); + + # Day 60 in non-leap year = Mar 1 + $t = str2time("2025-060", "GMT"); + is(strftime("%Y-%m-%d", gmtime($t)), "2025-03-01", "2025-060 = Mar 1 (non-leap year)"); + + # Day 366 in non-leap year = invalid + $t = str2time("2025-366", "GMT"); + ok(!defined $t, "2025-366 rejected (non-leap year has only 365 days)"); +} + +# With time suffix +{ + my $t = str2time("2025-123T10:30:00", "GMT"); + is(strftime("%Y-%m-%d %H:%M:%S", gmtime($t)), "2025-05-03 10:30:00", + "2025-123T10:30:00 parses date and time"); + + $t = str2time("2025-123T10:30:00Z"); + is(strftime("%Y-%m-%d %H:%M:%S", gmtime($t)), "2025-05-03 10:30:00", + "2025-123T10:30:00Z parses with Z timezone"); + + # With fractional seconds + my @parts = strptime("2025-123T10:30:45.123"); + is($parts[0], 45.123, "fractional seconds preserved"); + is($parts[1], 30, "minutes correct"); + is($parts[2], 10, "hours correct"); + is($parts[3], 3, "day correct (May 3)"); + is($parts[4], 4, "month correct (May = 4, 0-indexed)"); +} + +# Invalid ordinal dates +{ + my $t = str2time("2025-000", "GMT"); + ok(!defined $t, "2025-000 rejected (day 0 invalid)"); + + $t = str2time("2025-367", "GMT"); + ok(!defined $t, "2025-367 rejected (day > 366)"); +} + +# strptime returns correct components +{ + my @t = strptime("2025-123"); + is($t[3], 3, "strptime day = 3 (May 3)"); + is($t[4], 4, "strptime month = 4 (May, 0-indexed)"); + is($t[5], 125, "strptime year = 125 (2025 - 1900)"); + is($t[7], 20, "strptime century = 20"); +} + +# Does not interfere with existing YYYY-MM-DD parsing +{ + my $t = str2time("2025-01-24", "GMT"); + is(strftime("%Y-%m-%d", gmtime($t)), "2025-01-24", + "YYYY-MM-DD still works correctly"); + + $t = str2time("2025-12-31", "GMT"); + is(strftime("%Y-%m-%d", gmtime($t)), "2025-12-31", + "YYYY-MM-DD end of year still works"); +} + +done_testing;