AK/Time+LibWeb/HTML: Fix ISO8601 week conversions

This reimplements conversions between unix date times and ISO8601
weeks. The new algorithms also do not use loops, so they should be
faster.
This commit is contained in:
Glenn Skrzypczak 2025-08-11 09:07:27 +02:00 committed by Tim Flynn
parent ec807d40dd
commit d25d62e74c
8 changed files with 268 additions and 44 deletions

View File

@ -282,23 +282,34 @@ MonotonicTime MonotonicTime::now_coarse()
UnixDateTime UnixDateTime::from_iso8601_week(u32 week_year, u32 week)
{
auto january_1_weekday = day_of_week(week_year, 1, 1);
i32 offset_to_monday = (january_1_weekday <= 3) ? -january_1_weekday : 7 - january_1_weekday;
i32 first_monday_of_year = 1 + offset_to_monday;
i32 day_of_year = (first_monday_of_year + (week - 1) * 7) + 1;
auto day_of_week_january_4th = (day_of_week(week_year, 1, 4) + 6) % 7;
int ordinal_day = (7 * week) - day_of_week_january_4th - 3;
// FIXME: There should be a more efficient way to do this that doesn't require a loop.
u8 month = 1;
while (true) {
auto days = days_in_month(week_year, month);
if (day_of_year <= days)
break;
if (ordinal_day < 1)
return UnixDateTime::from_ordinal_date(week_year - 1, ordinal_day + days_in_year(week_year - 1));
if (auto days_in_week_year = days_in_year(week_year); static_cast<unsigned>(ordinal_day) > days_in_week_year)
return UnixDateTime::from_ordinal_date(week_year + 1, ordinal_day - days_in_week_year);
return UnixDateTime::from_ordinal_date(week_year, ordinal_day);
}
day_of_year -= days;
++month;
}
UnixDateTime UnixDateTime::from_ordinal_date(u32 year, u32 day)
{
static constexpr Array<u32, 12> month_starts_normal = { 1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 };
static constexpr Array<u32, 12> month_starts_leap = { 1, 32, 61, 92, 122, 153, 183, 214, 245, 275, 306, 336 };
return UnixDateTime::from_unix_time_parts(week_year, month, static_cast<u8>(day_of_year), 0, 0, 0, 0);
auto const& month_starts = is_leap_year(year) ? month_starts_leap : month_starts_normal;
// Estimate month using integer division (approx 30.6 days per month)
auto estimated_month = (day * 12 + 6) / 367; // Gives 0-based month index
// Correct month if estimate overshot
if (day < month_starts[estimated_month])
--estimated_month;
auto month = estimated_month + 1; // convert to 1-based month
auto day_of_month = day - month_starts[estimated_month] + 1;
return UnixDateTime::from_unix_time_parts(year, month, day_of_month, 0, 0, 0, 0);
}
UnixDateTime UnixDateTime::now()

View File

@ -74,14 +74,14 @@ constexpr int day_of_year(int year, unsigned month, int day)
// Month starts at 1. Month must be >= 1 and <= 12.
int days_in_month(int year, unsigned month);
constexpr int days_in_year(int year)
constexpr unsigned int days_in_year(int year)
{
return 365 + (is_leap_year(year) ? 1 : 0);
}
constexpr int weeks_in_year(int year)
constexpr unsigned int iso8061_weeks_in_year(int year)
{
return is_leap_year(year) ? 53 : 52;
return day_of_week(year, 12, 31) == 4 || day_of_week(year - 1, 12, 31) == 3 ? 53 : 52;
}
namespace Detail {
@ -391,6 +391,9 @@ public:
// Creates UNIX time from an ISO 8601 Week, such as 2025-W06, with year 2025 and week 6.
[[nodiscard]] static UnixDateTime from_iso8601_week(u32 year, u32 week);
// Creates UNIX time from an ordinal date, such as 2025-100, with year 2025 and day 100.
[[nodiscard]] static UnixDateTime from_ordinal_date(u32 year, u32 day);
// Creates UNIX time from a unix timestamp.
// Note that the returned time is probably not equivalent to the same timestamp in UTC time, since UNIX time does not observe leap seconds.
[[nodiscard]] constexpr static UnixDateTime from_unix_time_parts(i32 year, u8 month, u8 day, u8 hour, u8 minute, u8 second, u16 millisecond)
@ -639,6 +642,7 @@ using AK::days_in_month;
using AK::days_in_year;
using AK::days_since_epoch;
using AK::is_leap_year;
using AK::iso8061_weeks_in_year;
using AK::MonotonicTime;
using AK::seconds_since_epoch_to_year;
using AK::timespec_add;
@ -649,6 +653,5 @@ using AK::timeval_add;
using AK::timeval_sub;
using AK::timeval_to_timespec;
using AK::UnixDateTime;
using AK::weeks_in_year;
using AK::years_to_days_since_epoch;
#endif

View File

@ -2375,33 +2375,27 @@ static Utf16String convert_number_to_month_string(double input)
return Utf16String::formatted("{:04d}-{:02d}", static_cast<int>(year), static_cast<int>(months) + 1);
}
// https://html.spec.whatwg.org/multipage/input.html#week-state-(type=week):concept-input-value-string-number
// https://html.spec.whatwg.org/multipage/input.html#concept-input-value-number-string
static Utf16String convert_number_to_week_string(double input)
{
// The algorithm to convert a number to a string, given a number input, is as follows: Return a valid week string that
// that represents the week that, in UTC, is current input milliseconds after midnight UTC on the morning of 1970-01-01
// (the time represented by the value "1970-01-01T00:00:00.0Z").
// The algorithm to convert a number to a string, given a number input, is as follows: Return a valid week string
// that represents the week that, in UTC, is current input milliseconds after midnight UTC on the morning of
// 1970-01-01 (the time represented by the value "1970-01-01T00:00:00.0Z").
int days_since_epoch = static_cast<int>(input / AK::ms_per_day);
int year = 1970;
auto year = JS::year_from_time(input);
auto month = JS::month_from_time(input) + 1; // Adjust for zero-based month
auto day = JS::date_from_time(input);
while (true) {
auto days = days_in_year(year);
if (days_since_epoch < days)
break;
days_since_epoch -= days;
constexpr Array normalYearDays = { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 };
constexpr Array leapYearDays = { 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 };
auto ordinal_day = (is_leap_year(year) ? leapYearDays[month - 1] : normalYearDays[month - 1]) + day;
unsigned week = (10 + ordinal_day - 1) / 7; // -1 because the day of the week is always monday
if (week > iso8061_weeks_in_year(year)) {
++year;
week = 1;
}
auto january_1_weekday = day_of_week(year, 1, 1);
int offset_to_week_start = (january_1_weekday <= 3) ? january_1_weekday : january_1_weekday - 7;
int week = (days_since_epoch + offset_to_week_start) / 7 + 1;
if (week < 0) {
--year;
week = weeks_in_year(year) + week;
}
return Utf16String::formatted("{:04d}-W{:02d}", year, week);
}

View File

@ -554,6 +554,77 @@ TEST_CASE(user_defined_literals)
static_assert(1_sec != 2_sec, "NE UDL");
}
TEST_CASE(from_iso8601_week)
{
// 1970-W01
EXPECT_DURATION(UnixDateTime::from_iso8601_week(1970, 1).offset_to_epoch(), -259'200, 0);
// First and last weeks of yearEXPECT_DURATION(UnixDateTime::from_iso8601_week(2000, 1).offset_to_epoch(), 946857600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2000, 2).offset_to_epoch(), 947462400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2000, 51).offset_to_epoch(), 977097600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2000, 52).offset_to_epoch(), 977702400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2001, 1).offset_to_epoch(), 978307200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2001, 2).offset_to_epoch(), 978912000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2001, 51).offset_to_epoch(), 1008547200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2001, 52).offset_to_epoch(), 1009152000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2002, 1).offset_to_epoch(), 1009756800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2002, 2).offset_to_epoch(), 1010361600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2002, 51).offset_to_epoch(), 1039996800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2002, 52).offset_to_epoch(), 1040601600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2003, 1).offset_to_epoch(), 1041206400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2003, 2).offset_to_epoch(), 1041811200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2003, 51).offset_to_epoch(), 1071446400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2003, 52).offset_to_epoch(), 1072051200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2004, 1).offset_to_epoch(), 1072656000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2004, 2).offset_to_epoch(), 1073260800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2004, 51).offset_to_epoch(), 1102896000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2004, 52).offset_to_epoch(), 1103500800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2004, 53).offset_to_epoch(), 1104105600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2005, 1).offset_to_epoch(), 1104710400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2005, 2).offset_to_epoch(), 1105315200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2005, 51).offset_to_epoch(), 1134950400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2005, 52).offset_to_epoch(), 1135555200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2006, 1).offset_to_epoch(), 1136160000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2006, 2).offset_to_epoch(), 1136764800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2006, 51).offset_to_epoch(), 1166400000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2006, 52).offset_to_epoch(), 1167004800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2007, 1).offset_to_epoch(), 1167609600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2007, 2).offset_to_epoch(), 1168214400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2007, 51).offset_to_epoch(), 1197849600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2007, 52).offset_to_epoch(), 1198454400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2008, 1).offset_to_epoch(), 1199059200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2008, 2).offset_to_epoch(), 1199664000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2008, 51).offset_to_epoch(), 1229299200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2008, 52).offset_to_epoch(), 1229904000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2009, 1).offset_to_epoch(), 1230508800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2009, 2).offset_to_epoch(), 1231113600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2009, 51).offset_to_epoch(), 1260748800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2009, 52).offset_to_epoch(), 1261353600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2009, 53).offset_to_epoch(), 1261958400, 0);
// Some random weeks
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2064, 25).offset_to_epoch(), 2980800000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(1976, 20).offset_to_epoch(), 200534400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2036, 6).offset_to_epoch(), 2085696000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2022, 9).offset_to_epoch(), 1646006400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2049, 19).offset_to_epoch(), 2504217600, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2069, 9).offset_to_epoch(), 3128976000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2015, 46).offset_to_epoch(), 1447027200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2056, 42).offset_to_epoch(), 2738880000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2018, 23).offset_to_epoch(), 1528070400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2047, 2).offset_to_epoch(), 2430432000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2033, 41).offset_to_epoch(), 2012515200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2021, 52).offset_to_epoch(), 1640563200, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(1987, 8).offset_to_epoch(), 540432000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(1982, 13).offset_to_epoch(), 386208000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(1979, 36).offset_to_epoch(), 305164800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2057, 43).offset_to_epoch(), 2770934400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(1988, 35).offset_to_epoch(), 588816000, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2060, 50).offset_to_epoch(), 2869516800, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2013, 14).offset_to_epoch(), 1364774400, 0);
EXPECT_DURATION(UnixDateTime::from_iso8601_week(2005, 52).offset_to_epoch(), 1135555200, 0);
}
TEST_CASE(from_unix_time_parts_common_values)
{
// Non-negative "common" values.

View File

@ -33,7 +33,7 @@ email threw exception: InvalidStateError: valueAsNumber: Invalid input type used
password threw exception: InvalidStateError: valueAsNumber: Invalid input type used
date did not throw: 0
month did not throw: 100
week did not throw: 345600000
week did not throw: -259200000
time did not throw: 100
datetime-local did not throw: 100
color threw exception: InvalidStateError: valueAsNumber: Invalid input type used

View File

@ -0,0 +1,62 @@
1970-W01 -> -259200000
2000-W02 -> 947462400000
2000-W51 -> 977097600000
2000-W52 -> 977702400000
2001-W01 -> 978307200000
2001-W02 -> 978912000000
2001-W51 -> 1008547200000
2001-W52 -> 1009152000000
2002-W01 -> 1009756800000
2002-W02 -> 1010361600000
2002-W51 -> 1039996800000
2002-W52 -> 1040601600000
2003-W01 -> 1041206400000
2003-W02 -> 1041811200000
2003-W51 -> 1071446400000
2003-W52 -> 1072051200000
2004-W01 -> 1072656000000
2004-W02 -> 1073260800000
2004-W51 -> 1102896000000
2004-W52 -> 1103500800000
2004-W53 -> 1104105600000
2005-W01 -> 1104710400000
2005-W02 -> 1105315200000
2005-W51 -> 1134950400000
2005-W52 -> 1135555200000
2006-W01 -> 1136160000000
2006-W02 -> 1136764800000
2006-W51 -> 1166400000000
2006-W52 -> 1167004800000
2007-W01 -> 1167609600000
2007-W02 -> 1168214400000
2007-W51 -> 1197849600000
2007-W52 -> 1198454400000
2008-W01 -> 1199059200000
2008-W02 -> 1199664000000
2008-W51 -> 1229299200000
2008-W52 -> 1229904000000
2009-W01 -> 1230508800000
2009-W02 -> 1231113600000
2009-W51 -> 1260748800000
2009-W52 -> 1261353600000
2009-W53 -> 1261958400000
2064-W25 -> 2980800000000
1976-W20 -> 200534400000
2036-W06 -> 2085696000000
2022-W09 -> 1646006400000
2049-W19 -> 2504217600000
2069-W09 -> 3128976000000
2015-W46 -> 1447027200000
2056-W42 -> 2738880000000
2018-W23 -> 1528070400000
2047-W02 -> 2430432000000
2033-W41 -> 2012515200000
2021-W52 -> 1640563200000
1987-W08 -> 540432000000
1982-W13 -> 386208000000
1979-W36 -> 305164800000
2057-W43 -> 2770934400000
1988-W35 -> 588816000000
2060-W50 -> 2869516800000
2013-W14 -> 1364774400000
2005-W52 -> 1135555200000

View File

@ -2,8 +2,8 @@ Harness status: OK
Found 28 tests
24 Pass
4 Fail
26 Pass
2 Fail
Pass [INPUT in DATE status] The step attribute is not set
Pass [INPUT in DATE status] The value attibute is empty string
Pass [INPUT in DATE status] The value must match the step
@ -14,8 +14,8 @@ Pass [INPUT in MONTH status] The value must match the step
Pass [INPUT in MONTH status] The value must mismatch the step
Pass [INPUT in WEEK status] The step attribute is not set
Pass [INPUT in WEEK status] The value attibute is empty string
Fail [INPUT in WEEK status] The value must match the step
Fail [INPUT in WEEK status] The value must mismatch the step
Pass [INPUT in WEEK status] The value must match the step
Pass [INPUT in WEEK status] The value must mismatch the step
Pass [INPUT in TIME status] The step attribute is not set
Pass [INPUT in TIME status] The value attibute is empty string
Pass [INPUT in TIME status] The value must match the step

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<script src="./include.js"></script>
<script>
const test_cases = [
"1970-W01",
"2000-W02",
"2000-W51",
"2000-W52",
"2001-W01",
"2001-W02",
"2001-W51",
"2001-W52",
"2002-W01",
"2002-W02",
"2002-W51",
"2002-W52",
"2003-W01",
"2003-W02",
"2003-W51",
"2003-W52",
"2004-W01",
"2004-W02",
"2004-W51",
"2004-W52",
"2004-W53",
"2005-W01",
"2005-W02",
"2005-W51",
"2005-W52",
"2006-W01",
"2006-W02",
"2006-W51",
"2006-W52",
"2007-W01",
"2007-W02",
"2007-W51",
"2007-W52",
"2008-W01",
"2008-W02",
"2008-W51",
"2008-W52",
"2009-W01",
"2009-W02",
"2009-W51",
"2009-W52",
"2009-W53",
"2064-W25",
"1976-W20",
"2036-W06",
"2022-W09",
"2049-W19",
"2069-W09",
"2015-W46",
"2056-W42",
"2018-W23",
"2047-W02",
"2033-W41",
"2021-W52",
"1987-W08",
"1982-W13",
"1979-W36",
"2057-W43",
"1988-W35",
"2060-W50",
"2013-W14",
"2005-W52",
];
test(() => {
for (const test_case of test_cases) {
const input = document.createElement("input");
input.type = "week";
input.value = test_case;
if (input.value === test_case) {
println(`${test_case} -> ${input.valueAsNumber}`);
} else {
println("FAIL");
}
}
});
</script>