Skip to content

Commit 66bee81

Browse files
committed
Adds timezone support to DateMath
Extends the DateMath utility to support TimeZoneInfo, enabling accurate date parsing and calculations within specific timezones. This enhancement allows for parsing expressions using a specified timezone, ensuring that "now" calculations and dates without explicit timezone information are correctly interpreted. Dates with explicit timezone information are preserved, regardless of the TimeZoneInfo parameter.
1 parent a3c045e commit 66bee81

File tree

3 files changed

+364
-0
lines changed

3 files changed

+364
-0
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,34 @@ var utcResult = DateMath.Parse("2025-01-01T01:25:35Z||+3d/d", baseTime);
9999
var offsetResult = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", baseTime);
100100
```
101101

102+
#### TimeZone-Aware DateMath
103+
104+
The `DateMath` utility also provides overloads that work directly with `TimeZoneInfo` for better timezone handling:
105+
106+
```csharp
107+
using Exceptionless.DateTimeExtensions;
108+
109+
// Parse expressions using a specific timezone
110+
var utcTimeZone = TimeZoneInfo.Utc;
111+
var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern");
112+
113+
// "now" will use current time in the specified timezone
114+
var utcResult = DateMath.Parse("now+1h", utcTimeZone);
115+
var easternResult = DateMath.Parse("now/d", easternTimeZone, isUpperLimit: false);
116+
117+
// TryParse with timezone
118+
if (DateMath.TryParse("now+2d-3h", easternTimeZone, false, out var result)) {
119+
Console.WriteLine($"Eastern time result: {result:O}");
120+
}
121+
122+
// Dates without explicit timezone use the provided TimeZoneInfo
123+
var localDate = DateMath.Parse("2023-06-15T14:30:00||+1M", easternTimeZone);
124+
125+
// Dates with explicit timezone are preserved regardless of TimeZoneInfo parameter
126+
var preservedTz = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", easternTimeZone);
127+
// Result will still have +05:00 offset, not Eastern time offset
128+
```
129+
102130
The `DateMath` utility supports the same comprehensive syntax as `DateTimeRange` but provides a simpler API for direct parsing operations.
103131

104132
### TimeUnit

src/Exceptionless.DateTimeExtensions/DateMath.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,52 @@ public static bool TryParse(string expression, DateTimeOffset relativeBaseTime,
6767
return TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out result);
6868
}
6969

70+
/// <summary>
71+
/// Parses a date math expression and returns the resulting DateTimeOffset using the specified timezone.
72+
/// </summary>
73+
/// <param name="expression">The date math expression to parse</param>
74+
/// <param name="timeZone">The timezone to use for 'now' calculations and dates without explicit timezone information</param>
75+
/// <param name="isUpperLimit">Whether this is for an upper limit (affects rounding behavior)</param>
76+
/// <returns>The parsed DateTimeOffset</returns>
77+
/// <exception cref="ArgumentException">Thrown when the expression is invalid or cannot be parsed</exception>
78+
/// <exception cref="ArgumentNullException">Thrown when timeZone is null</exception>
79+
public static DateTimeOffset Parse(string expression, TimeZoneInfo timeZone, bool isUpperLimit = false)
80+
{
81+
if (timeZone == null)
82+
throw new ArgumentNullException(nameof(timeZone));
83+
84+
if (!TryParse(expression, timeZone, isUpperLimit, out DateTimeOffset result))
85+
throw new ArgumentException($"Invalid date math expression: {expression}", nameof(expression));
86+
87+
return result;
88+
}
89+
90+
/// <summary>
91+
/// Tries to parse a date math expression and returns the resulting DateTimeOffset using the specified timezone.
92+
/// </summary>
93+
/// <param name="expression">The date math expression to parse</param>
94+
/// <param name="timeZone">The timezone to use for 'now' calculations and dates without explicit timezone information</param>
95+
/// <param name="isUpperLimit">Whether this is for an upper limit (affects rounding behavior)</param>
96+
/// <param name="result">The parsed DateTimeOffset if successful</param>
97+
/// <returns>True if parsing succeeded, false otherwise</returns>
98+
/// <exception cref="ArgumentNullException">Thrown when timeZone is null</exception>
99+
public static bool TryParse(string expression, TimeZoneInfo timeZone, bool isUpperLimit, out DateTimeOffset result)
100+
{
101+
if (timeZone == null)
102+
throw new ArgumentNullException(nameof(timeZone));
103+
104+
result = default;
105+
106+
if (String.IsNullOrEmpty(expression))
107+
return false;
108+
109+
var match = Parser.Match(expression);
110+
if (!match.Success)
111+
return false;
112+
113+
return TryParseFromMatch(match, timeZone, isUpperLimit, out result);
114+
}
115+
70116
/// <summary>
71117
/// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset.
72118
/// This method bypasses the regex matching for cases where the match is already available.
@@ -109,6 +155,50 @@ public static bool TryParseFromMatch(Match match, DateTimeOffset relativeBaseTim
109155
}
110156
}
111157

158+
/// <summary>
159+
/// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset using the specified timezone.
160+
/// This method bypasses the regex matching for cases where the match is already available.
161+
/// </summary>
162+
/// <param name="match">The regex match containing the parsed expression groups</param>
163+
/// <param name="timeZone">The timezone to use for 'now' calculations and dates without explicit timezone information</param>
164+
/// <param name="isUpperLimit">Whether this is for an upper limit (affects rounding behavior)</param>
165+
/// <param name="result">The parsed DateTimeOffset if successful</param>
166+
/// <returns>True if parsing succeeded, false otherwise</returns>
167+
public static bool TryParseFromMatch(Match match, TimeZoneInfo timeZone, bool isUpperLimit, out DateTimeOffset result)
168+
{
169+
result = default;
170+
171+
try
172+
{
173+
// Parse the anchor (now or explicit date)
174+
DateTimeOffset baseTime;
175+
string anchor = match.Groups["anchor"].Value;
176+
177+
if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
178+
{
179+
// Use current time in the specified timezone
180+
baseTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone);
181+
}
182+
else
183+
{
184+
// Parse explicit date from the date group
185+
string dateStr = match.Groups["date"].Value;
186+
TimeSpan offset = timeZone.GetUtcOffset(DateTime.UtcNow);
187+
if (!TryParseExplicitDate(dateStr, offset, out baseTime))
188+
return false;
189+
}
190+
191+
// Parse and apply operations
192+
string operations = match.Groups["operations"].Value;
193+
result = ApplyOperations(baseTime, operations, isUpperLimit);
194+
return true;
195+
}
196+
catch
197+
{
198+
return false;
199+
}
200+
}
201+
112202
/// <summary>
113203
/// Checks if the given expression is a valid date math expression.
114204
/// </summary>

tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,4 +511,250 @@ public void Parse_ComplexExpression_MultipleOperationsWithRounding()
511511
// Should not equal base time
512512
Assert.NotEqual(_baseTime, result);
513513
}
514+
515+
[Fact]
516+
public void Parse_WithTimeZoneInfo_Now_ReturnsCurrentTimeInSpecifiedTimezone()
517+
{
518+
var utcTimeZone = TimeZoneInfo.Utc;
519+
const string expression = "now";
520+
521+
_logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
522+
expression, utcTimeZone.Id);
523+
524+
var result = DateMath.Parse(expression, utcTimeZone);
525+
526+
_logger.LogDebug("Parse result: {Result}", result);
527+
528+
// Should be close to current UTC time
529+
var utcNow = DateTimeOffset.UtcNow;
530+
Assert.True(Math.Abs((result - utcNow).TotalSeconds) < 5,
531+
$"Result {result} should be within 5 seconds of UTC now {utcNow}");
532+
Assert.Equal(TimeSpan.Zero, result.Offset); // Should be UTC
533+
}
534+
535+
[Theory]
536+
[InlineData("UTC", 0)]
537+
[InlineData("US/Eastern", -5)] // EST offset (not considering DST for this test)
538+
[InlineData("US/Pacific", -8)] // PST offset (not considering DST for this test)
539+
public void Parse_WithTimeZoneInfo_Now_ReturnsCorrectTimezone(string timeZoneId, int expectedOffsetHours)
540+
{
541+
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
542+
const string expression = "now";
543+
544+
_logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
545+
expression, timeZone.Id);
546+
547+
var result = DateMath.Parse(expression, timeZone);
548+
549+
_logger.LogDebug("Parse result: {Result}, Expected offset hours: {ExpectedOffsetHours}", result, expectedOffsetHours);
550+
551+
// Note: This test might need adjustment for DST, but it demonstrates the concept
552+
Assert.Equal(timeZone.GetUtcOffset(DateTime.UtcNow), result.Offset);
553+
}
554+
555+
[Fact]
556+
public void Parse_WithTimeZoneInfo_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone()
557+
{
558+
var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern");
559+
const string expression = "2023-06-15T14:30:00||";
560+
561+
_logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
562+
expression, easternTimeZone.Id);
563+
564+
var result = DateMath.Parse(expression, easternTimeZone);
565+
566+
_logger.LogDebug("Parse result: {Result}", result);
567+
568+
Assert.Equal(2023, result.Year);
569+
Assert.Equal(6, result.Month);
570+
Assert.Equal(15, result.Day);
571+
Assert.Equal(14, result.Hour);
572+
Assert.Equal(30, result.Minute);
573+
Assert.Equal(0, result.Second);
574+
575+
// Should use the timezone offset from Eastern Time
576+
var expectedOffset = easternTimeZone.GetUtcOffset(new DateTime(2023, 6, 15, 14, 30, 0));
577+
Assert.Equal(expectedOffset, result.Offset);
578+
}
579+
580+
[Fact]
581+
public void Parse_WithTimeZoneInfo_ExplicitDateWithTimezone_PreservesOriginalTimezone()
582+
{
583+
var pacificTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Pacific");
584+
const string expression = "2023-06-15T14:30:00+05:00||"; // Explicit +05:00 timezone
585+
586+
_logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
587+
expression, pacificTimeZone.Id);
588+
589+
var result = DateMath.Parse(expression, pacificTimeZone);
590+
591+
_logger.LogDebug("Parse result: {Result}", result);
592+
593+
Assert.Equal(2023, result.Year);
594+
Assert.Equal(6, result.Month);
595+
Assert.Equal(15, result.Day);
596+
Assert.Equal(14, result.Hour);
597+
Assert.Equal(30, result.Minute);
598+
Assert.Equal(0, result.Second);
599+
600+
// Should preserve the original +05:00 timezone, not use Pacific
601+
Assert.Equal(TimeSpan.FromHours(5), result.Offset);
602+
}
603+
604+
[Theory]
605+
[InlineData("now+1h", 1)]
606+
[InlineData("now+6h", 6)]
607+
[InlineData("now-2h", -2)]
608+
[InlineData("now+24h", 24)]
609+
public void Parse_WithTimeZoneInfo_HourOperations_ReturnsCorrectResult(string expression, int hours)
610+
{
611+
var utcTimeZone = TimeZoneInfo.Utc;
612+
613+
_logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, Hours: {Hours}",
614+
expression, utcTimeZone.Id, hours);
615+
616+
var result = DateMath.Parse(expression, utcTimeZone);
617+
var utcNow = DateTimeOffset.UtcNow;
618+
var expected = utcNow.AddHours(hours);
619+
620+
_logger.LogDebug("Parse result: {Result}, Expected: approximately {Expected}", result, expected);
621+
622+
// Should be close to expected time (within 5 seconds to account for execution time)
623+
Assert.True(Math.Abs((result - expected).TotalSeconds) < 5,
624+
$"Result {result} should be within 5 seconds of expected {expected}");
625+
Assert.Equal(TimeSpan.Zero, result.Offset); // Should be UTC
626+
}
627+
628+
[Theory]
629+
[InlineData("now/d", false)]
630+
[InlineData("now/d", true)]
631+
[InlineData("now/h", false)]
632+
[InlineData("now/h", true)]
633+
[InlineData("now/M", false)]
634+
[InlineData("now/M", true)]
635+
public void Parse_WithTimeZoneInfo_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit)
636+
{
637+
var centralTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Central");
638+
639+
_logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, IsUpperLimit: {IsUpperLimit}",
640+
expression, centralTimeZone.Id, isUpperLimit);
641+
642+
var result = DateMath.Parse(expression, centralTimeZone, isUpperLimit);
643+
644+
_logger.LogDebug("Parse result: {Result}", result);
645+
646+
// Verify the result uses Central Time offset
647+
var expectedOffset = centralTimeZone.GetUtcOffset(DateTime.UtcNow);
648+
Assert.Equal(expectedOffset, result.Offset);
649+
650+
// Verify rounding behavior
651+
if (expression.EndsWith("/d"))
652+
{
653+
if (isUpperLimit)
654+
{
655+
Assert.Equal(23, result.Hour);
656+
Assert.Equal(59, result.Minute);
657+
Assert.Equal(59, result.Second);
658+
}
659+
else
660+
{
661+
Assert.Equal(0, result.Hour);
662+
Assert.Equal(0, result.Minute);
663+
Assert.Equal(0, result.Second);
664+
}
665+
}
666+
else if (expression.EndsWith("/h"))
667+
{
668+
if (isUpperLimit)
669+
{
670+
Assert.Equal(59, result.Minute);
671+
Assert.Equal(59, result.Second);
672+
}
673+
else
674+
{
675+
Assert.Equal(0, result.Minute);
676+
Assert.Equal(0, result.Second);
677+
}
678+
}
679+
}
680+
681+
[Fact]
682+
public void TryParse_WithTimeZoneInfo_ValidExpression_ReturnsTrue()
683+
{
684+
var mountainTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Mountain");
685+
const string expression = "now+2d";
686+
687+
_logger.LogDebug("Testing TryParse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
688+
expression, mountainTimeZone.Id);
689+
690+
bool success = DateMath.TryParse(expression, mountainTimeZone, false, out DateTimeOffset result);
691+
692+
_logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result);
693+
694+
Assert.True(success);
695+
Assert.NotEqual(default(DateTimeOffset), result);
696+
697+
// Should use Mountain Time offset
698+
var expectedOffset = mountainTimeZone.GetUtcOffset(DateTime.UtcNow);
699+
Assert.Equal(expectedOffset, result.Offset);
700+
}
701+
702+
[Fact]
703+
public void TryParse_WithTimeZoneInfo_InvalidExpression_ReturnsFalse()
704+
{
705+
var utcTimeZone = TimeZoneInfo.Utc;
706+
const string expression = "invalid_expression";
707+
708+
_logger.LogDebug("Testing TryParse with TimeZoneInfo for invalid expression: '{Expression}', TimeZone: {TimeZone}",
709+
expression, utcTimeZone.Id);
710+
711+
bool success = DateMath.TryParse(expression, utcTimeZone, false, out DateTimeOffset result);
712+
713+
_logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result);
714+
715+
Assert.False(success);
716+
Assert.Equal(default(DateTimeOffset), result);
717+
}
718+
719+
[Fact]
720+
public void Parse_WithTimeZoneInfo_ComplexExpression_WorksCorrectly()
721+
{
722+
var utcTimeZone = TimeZoneInfo.Utc;
723+
const string expression = "now+1M-2d+3h/h";
724+
725+
_logger.LogDebug("Testing Parse with TimeZoneInfo for complex expression: '{Expression}', TimeZone: {TimeZone}",
726+
expression, utcTimeZone.Id);
727+
728+
var result = DateMath.Parse(expression, utcTimeZone, false);
729+
730+
_logger.LogDebug("Parse result: {Result}", result);
731+
732+
// Should be UTC
733+
Assert.Equal(TimeSpan.Zero, result.Offset);
734+
735+
// Should be rounded to start of hour
736+
Assert.Equal(0, result.Minute);
737+
Assert.Equal(0, result.Second);
738+
Assert.Equal(0, result.Millisecond);
739+
}
740+
741+
[Fact]
742+
public void Parse_WithTimeZoneInfo_NullTimeZone_ThrowsArgumentNullException()
743+
{
744+
const string expression = "now";
745+
746+
_logger.LogDebug("Testing Parse with null TimeZoneInfo for expression: '{Expression}'", expression);
747+
748+
Assert.Throws<ArgumentNullException>(() => DateMath.Parse(expression, (TimeZoneInfo)null!));
749+
}
750+
751+
[Fact]
752+
public void TryParse_WithTimeZoneInfo_NullTimeZone_ThrowsArgumentNullException()
753+
{
754+
const string expression = "now";
755+
756+
_logger.LogDebug("Testing TryParse with null TimeZoneInfo for expression: '{Expression}'", expression);
757+
758+
Assert.Throws<ArgumentNullException>(() => DateMath.TryParse(expression, (TimeZoneInfo)null!, false, out _));
759+
}
514760
}

0 commit comments

Comments
 (0)