Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c2f7e7a
Initial plan
Copilot Feb 8, 2026
4d9e862
Implement ISO 8601 24:00 support for DateTime/DateTimeOffset parsing
Copilot Feb 8, 2026
47a7b98
Remove redundant test cases per code review feedback
Copilot Feb 8, 2026
3a3f433
Refactor Utf8Parser hour=24 handling to eliminate code duplication
Copilot Feb 8, 2026
175ded8
Add hour=24 support to DoStrictParse for TimeOnly.ParseExact
Copilot Feb 8, 2026
fcb6666
Clarify ISO 8601 comments with reference to specification
Copilot Feb 8, 2026
f9455b5
Add hour=24 support to ParseISO8601 method
Copilot Feb 8, 2026
deb48f9
Refactor DateTime.TryCreate to use consistent hour=24 pattern
Copilot Feb 8, 2026
2917efa
Add hour=24 support to JSON date parsing
Copilot Feb 8, 2026
2b7a247
Fix build error: use DateTime.MaxValue.Ticks instead of DateTime.MaxT…
Copilot Feb 8, 2026
0eda192
Merge branch 'main' into copilot/support-24-00-hh-mm-iso8601
stephentoub Feb 8, 2026
fe8cb92
Fix trailing blank line in test data file
Copilot Feb 8, 2026
5539e22
Add hour=24 support to heuristic parser (ProcessDateTimeSuffix)
Copilot Feb 8, 2026
619476f
Remove duplicate hour=24 fraction validation from Utf8Parser.Date.O.cs
Copilot Feb 8, 2026
0b14812
Merge branch 'main' into copilot/support-24-00-hh-mm-iso8601
tarekgh Feb 8, 2026
efb163d
Apply suggestions from code review
tarekgh Feb 8, 2026
66e0ae0
Make TimeOnly test methods static; remove last iso8601.com URL
Copilot Feb 8, 2026
ab7028e
Fix hour=24 fraction check: use > 0 instead of != 0
Copilot Feb 8, 2026
51f074f
Move "1997-07-16T24:00" from invalid to valid JSON test data
Copilot Feb 9, 2026
955e82d
Fix JSON hour=24 test: split Z-suffixed case into separate UTC test
Copilot Feb 9, 2026
1e90b0a
Update src/libraries/System.Private.CoreLib/src/System/DateTime.cs
tarekgh Feb 10, 2026
4a03e66
Merge branch 'main' into copilot/support-24-00-hh-mm-iso8601
tarekgh Feb 10, 2026
ba22785
Merge branch 'main' into copilot/support-24-00-hh-mm-iso8601
tarekgh Feb 13, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ public static IEnumerable<ParserTestData<DateTimeOffset>> DateTimeOffsetParserTe
yield return bad;
}

// Tests for ISO 8601 24:00 support (end of day)
// Valid 24:00 cases - should advance to next day at 00:00
yield return new ParserTestData<DateTimeOffset>("2007-04-05T24:00:00.0000000Z", new DateTimeOffset(2007, 4, 6, 0, 0, 0, TimeSpan.Zero), 'O', expectedSuccess: true) { ExpectedBytesConsumed = 28 };
yield return new ParserTestData<DateTimeOffset>("2007-04-05T24:00:00.0000000+00:00", new DateTimeOffset(2007, 4, 6, 0, 0, 0, TimeSpan.Zero), 'O', expectedSuccess: true) { ExpectedBytesConsumed = 33 };
yield return new ParserTestData<DateTimeOffset>("2023-12-31T24:00:00.0000000Z", new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), 'O', expectedSuccess: true) { ExpectedBytesConsumed = 28 };
yield return new ParserTestData<DateTimeOffset>("2020-02-29T24:00:00.0000000-05:00", new DateTimeOffset(2020, 3, 1, 0, 0, 0, new TimeSpan(-5, 0, 0)), 'O', expectedSuccess: true) { ExpectedBytesConsumed = 33 };

// Invalid 24:00 cases - non-zero minute, second, or fraction
yield return new ParserTestData<DateTimeOffset>("2007-04-05T24:00:01.0000000Z", default, 'O', expectedSuccess: false);
yield return new ParserTestData<DateTimeOffset>("2007-04-05T24:01:00.0000000Z", default, 'O', expectedSuccess: false);
yield return new ParserTestData<DateTimeOffset>("2007-04-05T24:00:00.0000001Z", default, 'O', expectedSuccess: false);
yield return new ParserTestData<DateTimeOffset>("9999-12-31T24:00:00.0000000Z", default, 'O', expectedSuccess: false); // Would overflow

foreach (ParserTestData<DateTimeOffset> testData in DateTimeOffsetFormatterTestData.ToParserTheoryDataCollection())
{
bool roundTrippable = testData.FormatSymbol == 'O';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ private static bool TryCreateDateTime(int year, int month, int day, int hour, in
return false;
}

// Per ISO 8601 (Date and time — Representations for information interchange), 24:00:00 represents end of a calendar day
// (same instant as next day's 00:00:00), but only when minute, second, and fraction are all zero.
// We treat it as hour=0 and add one day at the end.
bool isEndOfDay = false;
if (hour == 24)
{
if (minute != 0 || second != 0 || fraction != 0)
{
value = default;
return false;
}

hour = 0;
isEndOfDay = true;
}

if (((uint)hour) > 23)
{
value = default;
Expand Down Expand Up @@ -152,6 +168,18 @@ private static bool TryCreateDateTime(int year, int month, int day, int hour, in
int totalSeconds = (hour * 3600) + (minute * 60) + second;
ticks += totalSeconds * TimeSpan.TicksPerSecond;
ticks += fraction;

// If hour was originally 24 (end of day per ISO 8601), add one day to advance to next day's 00:00:00
if (isEndOfDay)
{
ticks += TimeSpan.TicksPerDay;
if ((ulong)ticks > DateTime.MaxTicks)
{
value = default;
return false;
}
}

value = new DateTime(ticks: ticks, kind: kind);
return true;
}
Expand Down
28 changes: 27 additions & 1 deletion src/libraries/System.Private.CoreLib/src/System/DateTime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1977,7 +1977,23 @@ internal static bool TryCreate(int year, int month, int day, int hour, int minut
{
return false;
}
if ((uint)hour >= 24 || (uint)minute >= 60 || (uint)millisecond >= TimeSpan.MillisecondsPerSecond)

// Per the ISO 8601 standard, 24:00:00 represents end of a calendar day
// (same instant as next day's 00:00:00), but only when minute, second, and millisecond are all zero.
// We treat it as hour=0 and add one day at the end.
bool isEndOfDay = false;
if (hour == 24)
{
if (minute != 0 || second != 0 || millisecond != 0)
{
return false;
}

hour = 0;
isEndOfDay = true;
}

if ((uint)hour > 24 || (uint)minute >= 60 || (uint)millisecond >= TimeSpan.MillisecondsPerSecond)
{
return false;
}
Expand Down Expand Up @@ -2010,6 +2026,16 @@ internal static bool TryCreate(int year, int month, int day, int hour, int minut
return false;
}

// If hour was originally 24 (end of day per ISO 8601), add one day to advance to next day's 00:00:00
if (isEndOfDay)
{
ticks += TimeSpan.TicksPerDay;
if (ticks > MaxTicks)
{
return false;
}
}

Debug.Assert(ticks <= MaxTicks, "Input parameters validated already");
result = new DateTime(ticks);
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2686,6 +2686,23 @@ internal static bool TryParse(ReadOnlySpan<char> s, DateTimeFormatInfo dtfi, Dat
return false;
}

// Per ISO 8601, 24:00:00 represents end of a calendar day
// (same instant as next day's 00:00:00), but only when minute, second, and fraction are all zero.
// We treat it as hour=0 and add one day at the end.
bool isEndOfDay = false;
if (result.Hour == 24)
{
if (result.Minute != 0 || result.Second != 0 || raw.fraction > 0)
{
result.SetBadDateTimeFailure();
TPTraceExit("0095 (hour 24 with non-zero minute/second/fraction)", dps);
return false;
}

result.Hour = 0;
isEndOfDay = true;
}

if (!result.calendar.TryToDateTime(result.Year, result.Month, result.Day,
result.Hour, result.Minute, result.Second, 0, result.era, out DateTime time))
{
Expand All @@ -2704,6 +2721,17 @@ internal static bool TryParse(ReadOnlySpan<char> s, DateTimeFormatInfo dtfi, Dat
}
}

// If hour was originally 24 (end of day per ISO 8601), add one day to advance to next day's 00:00:00
if (isEndOfDay)
{
if (!time.TryAddTicks(TimeSpan.TicksPerDay, out time))
{
result.SetBadDateTimeFailure();
TPTraceExit("0105 (hour 24 overflow adding one day)", dps);
return false;
}
}

//
// We have to check day of week before we adjust to the time zone.
// Otherwise, the value of day of week may change after adjusting to the time zone.
Expand Down Expand Up @@ -3049,6 +3077,22 @@ private static bool ParseISO8601(scoped ref DateTimeRawInfo raw, ref __DTString
}
}

// Per ISO 8601, 24:00:00 represents end of a calendar day
// (same instant as next day's 00:00:00), but only when minute, second, and fraction are all zero.
// We treat it as hour=0 and add one day at the end.
bool isEndOfDay = false;
if (hour == 24)
{
if (minute != 0 || second != 0 || partSecond != 0)
{
result.SetBadDateTimeFailure();
return false;
}

hour = 0;
isEndOfDay = true;
}

Calendar calendar = GregorianCalendar.GetDefaultInstance();
if (!calendar.TryToDateTime(raw.year, raw.GetNumber(0), raw.GetNumber(1),
hour, minute, second, 0, result.era, out DateTime time))
Expand All @@ -3063,6 +3107,16 @@ private static bool ParseISO8601(scoped ref DateTimeRawInfo raw, ref __DTString
return false;
}

// If hour was originally 24 (end of day per ISO 8601), add one day to advance to next day's 00:00:00
if (isEndOfDay)
{
if (!time.TryAddTicks(TimeSpan.TicksPerDay, out time))
{
result.SetBadDateTimeFailure();
return false;
}
}

result.parsedDate = time;
return DetermineTimeZoneAdjustments(ref result, styles, false);
}
Expand Down Expand Up @@ -4762,6 +4816,23 @@ private static bool DoStrictParse(
return false;
}
}

// Per ISO 8601:2004, 24:00:00 represents the end of a calendar day
// (the same instant as the next day's 00:00:00), but only when minute, second, and fraction are all zero.
// We treat it as hour=0 and add one day at the end.
bool isEndOfDay = false;
if (result.Hour == 24)
{
if (result.Minute != 0 || result.Second != 0 || result.fraction > 0)
{
result.SetBadDateTimeFailure();
return false;
}

result.Hour = 0;
isEndOfDay = true;
}

if (!parseInfo.calendar.TryToDateTime(result.Year, result.Month, result.Day,
result.Hour, result.Minute, result.Second, 0, result.era, out result.parsedDate))
{
Expand All @@ -4777,6 +4848,16 @@ private static bool DoStrictParse(
}
}

// If hour was originally 24 (end of day per ISO 8601), add one day to advance to next day's 00:00:00
if (isEndOfDay)
{
if (!result.parsedDate.TryAddTicks(TimeSpan.TicksPerDay, out result.parsedDate))
{
result.SetBadDateTimeFailure();
return false;
}
}

//
// We have to check day of week before we adjust to the time zone.
// It is because the value of day of week may change after adjusting
Expand Down Expand Up @@ -5109,6 +5190,14 @@ private static bool TryParseFormatO(ReadOnlySpan<char> source, scoped ref DateTi
fraction = (f1 * 1000000 + f2 * 100000 + f3 * 10000 + f4 * 1000 + f5 * 100 + f6 * 10 + f7) / 10000000.0;
}

// Per ISO 8601, 24:00:00 represents the end of a calendar day
// (the same instant as the next day's 00:00:00), but only when minute, second, and fraction are all zero
if (hour == 24 && (minute != 0 || second != 0 || fraction != 0))
{
result.SetBadDateTimeFailure();
return false;
}

if (!DateTime.TryCreate(year, month, day, hour, minute, second, 0, out DateTime dateTime))
{
result.SetBadDateTimeFailure();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1629,5 +1629,77 @@ public static void UnixEpoch()
{
VerifyDateTimeOffset(DateTimeOffset.UnixEpoch, 1970, 1, 1, 0, 0, 0, 0, 0, TimeSpan.Zero);
}

// Tests for ISO 8601 24:00 support (end of day) in DateTimeOffset
[Theory]
[InlineData("2007-04-05T24:00:00.0000000+00:00", 2007, 4, 6, 0, 0, 0)]
[InlineData("2023-12-31T24:00:00.0000000Z", 2024, 1, 1, 0, 0, 0)]
[InlineData("2020-02-29T24:00:00.0000000-05:00", 2020, 3, 1, 0, 0, 0)]
public static void ParseExact_Hour24_Success(string input, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute, int expectedSecond)
{
DateTimeOffset result = DateTimeOffset.ParseExact(input, "o", null);
Assert.Equal(expectedYear, result.Year);
Assert.Equal(expectedMonth, result.Month);
Assert.Equal(expectedDay, result.Day);
Assert.Equal(expectedHour, result.Hour);
Assert.Equal(expectedMinute, result.Minute);
Assert.Equal(expectedSecond, result.Second);
}

[Theory]
[InlineData("2007-04-05T24:00:01.0000000Z")] // Non-zero seconds
[InlineData("2007-04-05T24:01:00.0000000+00:00")] // Non-zero minutes
[InlineData("2007-04-05T24:00:00.0000001-05:00")] // Non-zero fraction
[InlineData("9999-12-31T24:00:00.0000000Z")] // Would overflow
public static void ParseExact_Hour24_Invalid_ThrowsFormatException(string input)
{
Assert.Throws<FormatException>(() => DateTimeOffset.ParseExact(input, "o", null));
}

[Fact]
public static void Parse_Hour24_BasicTest()
{
// Test with exact 'o' format
DateTimeOffset result = DateTimeOffset.Parse("2007-04-05T24:00:00.0000000Z");
Assert.Equal(new DateTimeOffset(2007, 4, 6, 0, 0, 0, TimeSpan.Zero), result);
}

[Theory]
[InlineData("2007-04-05T24:00:00Z")] // No fraction
[InlineData("2023-12-31T24:00:00+00:00")] // Year boundary with offset
[InlineData("2020-02-29T24:00:00-05:00")] // Leap year with negative offset
public static void Parse_Hour24_ISO8601Format_Success(string input)
{
// These use the ParseISO8601 code path
DateTimeOffset result = DateTimeOffset.Parse(input, CultureInfo.InvariantCulture);
// Verify the UTC date advanced by one day (hour=24 becomes next day at 00:00)
DateTimeOffset original = DateTimeOffset.Parse(input.Replace("24:00:00", "00:00:00"), CultureInfo.InvariantCulture);
Assert.Equal(original.AddDays(1).UtcDateTime, result.UtcDateTime);
}

[Theory]
[InlineData("2007-04-05T24:00:01Z")] // Non-zero seconds
[InlineData("2007-04-05T24:01:00+00:00")] // Non-zero minutes
[InlineData("2007-04-05T24:00:00.0000001-05:00")] // Non-zero fraction
public static void Parse_Hour24_ISO8601Format_Invalid_ThrowsFormatException(string input)
{
// These use the ParseISO8601 code path and should fail
Assert.Throws<FormatException>(() => DateTimeOffset.Parse(input, CultureInfo.InvariantCulture));
}

[Fact]
public static void TryParse_Hour24_Success()
{
bool success = DateTimeOffset.TryParseExact("2007-04-05T24:00:00.0000000+00:00", "o", null, DateTimeStyles.None, out DateTimeOffset result);
Assert.True(success);
Assert.Equal(new DateTimeOffset(2007, 4, 6, 0, 0, 0, TimeSpan.Zero), result);
}

[Fact]
public static void TryParse_Hour24_Invalid_ReturnsFalse()
{
bool success = DateTimeOffset.TryParseExact("2007-04-05T24:00:01.0000000Z", "o", null, DateTimeStyles.None, out DateTimeOffset result);
Assert.False(success);
}
}
}
Loading
Loading