From a0db18047562c415639458b0cf4af6ab876978e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Tue, 28 Apr 2026 06:20:57 -0600 Subject: [PATCH] feat: add %V format code for ISO 8601 week number Add format_V() implementing ISO 8601 week numbering (01-53), where week 1 contains the year's first Thursday and weeks start on Monday. Handles year-boundary edge cases: dates in early January that belong to the previous year's last week (52 or 53), and late December dates that belong to week 1 of the next year. Co-Authored-By: Claude Opus 4.6 --- lib/Date/Format.pm | 1 + lib/Date/Format/Generic.pm | 17 ++++++++++++ t/iso-week-number.t | 57 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 t/iso-week-number.t diff --git a/lib/Date/Format.pm b/lib/Date/Format.pm index 16c9f75..068c5e4 100644 --- a/lib/Date/Format.pm +++ b/lib/Date/Format.pm @@ -155,6 +155,7 @@ category of the program's locale. %t TAB %T time format: 21:05:57 %U week number, Sunday as first day of week + %V ISO 8601 week number (01-53) %w day of the week, numerically, Sunday == 0 %W week number, Monday as first day of week %x date format: 11/19/94 diff --git a/lib/Date/Format/Generic.pm b/lib/Date/Format/Generic.pm index c719ee9..dc56294 100644 --- a/lib/Date/Format/Generic.pm +++ b/lib/Date/Format/Generic.pm @@ -240,4 +240,21 @@ sub format_OY { roman(format_Y(@_)) } sub format_G { int(($_[0]->[9] - 315964800) / 604800) } +sub format_V { + my $yday = $_[0]->[7]; + my $year = $_[0]->[5] + 1900; + # Convert Perl wday (Sun=0) to Monday-based (Mon=0..Sun=6) + my $mwday = ($_[0]->[6] + 6) % 7; + # Day-of-year of the Thursday in this ISO week + my $thu = $yday - $mwday + 3; + if ($thu < 0) { + # Thursday falls in the previous year + my $py = $year - 1; + $thu += (($py % 4 == 0 && $py % 100 != 0) || $py % 400 == 0) ? 366 : 365; + } + my $ylen = (($year % 4 == 0 && $year % 100 != 0) || $year % 400 == 0) ? 366 : 365; + return sprintf("%02d", 1) if $thu >= $ylen; + sprintf("%02d", int($thu / 7) + 1); +} + 1; diff --git a/t/iso-week-number.t b/t/iso-week-number.t new file mode 100644 index 0000000..a683d73 --- /dev/null +++ b/t/iso-week-number.t @@ -0,0 +1,57 @@ +use strict; +use warnings; +use Test::More; +use Date::Format qw(time2str); +use Time::Local qw(timegm); + +# %V — ISO 8601 week number (01-53) +# Week 1 is the week containing the year's first Thursday. +# Weeks start on Monday. + +my @cases = ( + # [epoch_utc, expected_week, description] + + # 2024: Jan 1 is Monday → Week 1 starts Jan 1 + [timegm(0,0,0, 1,0,124), "01", "2024-01-01 (Mon) is week 01"], + [timegm(0,0,0, 7,0,124), "01", "2024-01-07 (Sun) is still week 01"], + [timegm(0,0,0, 8,0,124), "02", "2024-01-08 (Mon) is week 02"], + + # 2024-12-30 (Mon) is ISO week 1 of 2025 + [timegm(0,0,0,30,11,124), "01", "2024-12-30 (Mon) is week 01 of next year"], + [timegm(0,0,0,31,11,124), "01", "2024-12-31 (Tue) is week 01 of next year"], + + # 2023: Jan 1 is Sunday → belongs to week 52 of 2022 + [timegm(0,0,0, 1,0,123), "52", "2023-01-01 (Sun) is week 52 of prev year"], + [timegm(0,0,0, 2,0,123), "01", "2023-01-02 (Mon) is week 01"], + + # 2015: Jan 1 is Thursday → Week 1 starts Dec 29, 2014 + [timegm(0,0,0, 1,0,115), "01", "2015-01-01 (Thu) is week 01"], + [timegm(0,0,0,31,11,115), "53", "2015-12-31 (Thu) is week 53"], + + # 2016: Jan 1 is Friday → belongs to week 53 of 2015 + [timegm(0,0,0, 1,0,116), "53", "2016-01-01 (Fri) is week 53 of 2015"], + [timegm(0,0,0, 4,0,116), "01", "2016-01-04 (Mon) is week 01"], + + # 2026: Jan 1 is Thursday → Week 1 + [timegm(0,0,0, 1,0,126), "01", "2026-01-01 (Thu) is week 01"], + [timegm(0,0,0,28,3,126), "18", "2026-04-28 (Tue) is week 18"], + + # Mid-year sanity + [timegm(0,0,0, 1,6,124), "27", "2024-07-01 (Mon) is week 27"], + + # Year with 53 weeks: 2004 (Jan 1 is Thursday, leap year) + [timegm(0,0,0,27,11,104), "53", "2004-12-27 (Mon) is week 53"], + [timegm(0,0,0, 2,0,105), "53", "2005-01-02 (Sun) is week 53 of 2004"], + [timegm(0,0,0, 3,0,105), "01", "2005-01-03 (Mon) is week 01 of 2005"], +); + +for my $case (@cases) { + my ($epoch, $expected, $desc) = @$case; + is(time2str("%V", $epoch, "UTC"), $expected, $desc); +} + +# Verify %V always returns two-digit zero-padded string +like(time2str("%V", timegm(0,0,0,3,0,126), "UTC"), qr/^\d{2}$/, + "%V is always two digits"); + +done_testing;