Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/Date/Parse.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions t/iso-ordinal-date.t
Original file line number Diff line number Diff line change
@@ -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;
Loading