From 311292d0bf8883ca2a13230e6a5c46cb6945b0fb Mon Sep 17 00:00:00 2001 From: axunonb Date: Fri, 4 Oct 2024 23:23:00 +0200 Subject: [PATCH] Fix partly broken import of excluded match dates * Fix `GermanHoliday.ProcessHoliday` method to set correct `dateFrom` and `dateTo`. * Add more constructors to `GermanHolidayImporter` to import more than 1 file in one go. * Remove ignored test case and added new one in `GermanHolidayImporterTests`. * Rename `EnumerableValueTupleExtensions.cs` to `EnumerableRangeExtensions.cs` for consecutive range calculations. * Add `EnumerableValueTupleExtensionsTests` for `EnumerableValueTupleExtensions` methods.* --- Axuno.Tools/GermanHoliday.cs | 4 +- Axuno.Tools/GermanHolidays.cs | 18 ++-- .../Assets/Single_Holiday_To_Add.xml | 12 +++ .../Assets/Single_Holiday_To_Remove.xml | 8 ++ .../EnumerableValueTupleExtensionsTests.cs | 66 +++++++++++++ .../GermanHolidayImporterTests.cs | 60 +++++++++--- .../TournamentManager.Tests.csproj | 6 ++ .../ExcludeDates/EnumberableExtensions.cs | 53 ---------- .../ExcludeDates/EnumerableRangeExtensions.cs | 96 +++++++++++++++++++ .../ExcludeDates/GermanHolidayImporter.cs | 37 +++++-- 10 files changed, 277 insertions(+), 83 deletions(-) create mode 100644 TournamentManager/TournamentManager.Tests/Assets/Single_Holiday_To_Add.xml create mode 100644 TournamentManager/TournamentManager.Tests/Assets/Single_Holiday_To_Remove.xml create mode 100644 TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/EnumerableValueTupleExtensionsTests.cs delete mode 100644 TournamentManager/TournamentManager/Importers/ExcludeDates/EnumberableExtensions.cs create mode 100644 TournamentManager/TournamentManager/Importers/ExcludeDates/EnumerableRangeExtensions.cs diff --git a/Axuno.Tools/GermanHoliday.cs b/Axuno.Tools/GermanHoliday.cs index 8fdff97c..1659c23d 100644 --- a/Axuno.Tools/GermanHoliday.cs +++ b/Axuno.Tools/GermanHoliday.cs @@ -8,7 +8,7 @@ public class GermanHoliday /// /// CTOR. /// - /// Nullable HolidayId + /// A well-known or /// HolidayType /// Holiday name /// Function for date calculation @@ -26,7 +26,7 @@ internal GermanHoliday(GermanHolidays.Id? id, GermanHolidays.Type type, string n } /// - /// Constructor for usage from inside of class GermanHolidays. + /// Constructor for usage from class GermanHolidays. /// /// HolidayId /// HolidayType diff --git a/Axuno.Tools/GermanHolidays.cs b/Axuno.Tools/GermanHolidays.cs index 28caf616..c1e9fd2f 100644 --- a/Axuno.Tools/GermanHolidays.cs +++ b/Axuno.Tools/GermanHolidays.cs @@ -135,10 +135,10 @@ private DateTime GetEasterSunday() int tA, tB, tC, tD, tE; // tables A to E var firstDigits = Year / 100; - var remainding19 = Year % 19; + var remainder19 = Year % 19; // Calculate Paschal Full Moon - var temp = (firstDigits - 15) / 2 + 202 - 11 * remainding19; + var temp = (firstDigits - 15) / 2 + 202 - 11 * remainder19; switch (firstDigits) { case 21: @@ -168,7 +168,7 @@ private DateTime GetEasterSunday() tA = temp + 21; if (temp == 29) tA -= 1; - if (temp == 28 && remainding19 > 10) + if (temp == 28 && remainder19 > 10) tA -= 1; // Calculate next Sunday @@ -206,7 +206,7 @@ private DateTime GetEasterSunday() /// Advent date private DateTime GetAdventDate(int num) { - if (num < 1 || num > 4) + if (num is < 1 or > 4) throw new InvalidOperationException("Only Advents 1 to 4 are allowed."); // 4th Advent is the latest Sunday before 25th December @@ -294,7 +294,7 @@ private DateTime GetMuttertag() switch (holidayId) { - // general public holidays, are those where all federal states have the same public holidays defined + // national holidays, are those where all federal states have the same public holidays defined case Id.Neujahr: case Id.KarFreitag: case Id.OsterSonntag: @@ -458,6 +458,12 @@ private void ProcessHoliday(Id? holidayId, ActionType action, DateTime dateFrom, ValidateDateRange(action, dateFrom, dateTo); + var dateIsSet = dateFrom != DateTime.MinValue && dateTo != DateTime.MinValue; + if (holidayId.HasValue && !dateIsSet && Exists(h => h.Id == holidayId)) + { + dateFrom = dateTo = this[holidayId.Value]!.CalcDateFunc(); + } + while (dateFrom <= dateTo) { var tmpDateFrom = dateFrom; // no capture of modified closure @@ -508,7 +514,7 @@ private void ReplaceHoliday(Id? holidayId, GermanHoliday newHoliday) { if (!holidayId.HasValue || this[holidayId.Value] == null) throw new InvalidOperationException("Holiday to replace not found."); - + var existingHoliday = this[holidayId.Value]!; // Replace the existing holiday with the new one diff --git a/TournamentManager/TournamentManager.Tests/Assets/Single_Holiday_To_Add.xml b/TournamentManager/TournamentManager.Tests/Assets/Single_Holiday_To_Add.xml new file mode 100644 index 00000000..79a315cc --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/Assets/Single_Holiday_To_Add.xml @@ -0,0 +1,12 @@ + + + + Public + Sample New Year + 2024-01-01 + 2024-01-01 + + Bayern + + + diff --git a/TournamentManager/TournamentManager.Tests/Assets/Single_Holiday_To_Remove.xml b/TournamentManager/TournamentManager.Tests/Assets/Single_Holiday_To_Remove.xml new file mode 100644 index 00000000..69300ec5 --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/Assets/Single_Holiday_To_Remove.xml @@ -0,0 +1,8 @@ + + + + Public + 2024-01-01 + 2024-01-01 + + diff --git a/TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/EnumerableValueTupleExtensionsTests.cs b/TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/EnumerableValueTupleExtensionsTests.cs new file mode 100644 index 00000000..51efa1ad --- /dev/null +++ b/TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/EnumerableValueTupleExtensionsTests.cs @@ -0,0 +1,66 @@ +using NUnit.Framework; +using TournamentManager.Importers.ExcludeDates; + +namespace TournamentManager.Tests.Importers.ExcludeDates; + +[TestFixture] +internal class EnumerableValueTupleExtensionsTests +{ + [Test] + public void IntegerRanges_ShouldBeConsecutive() + { + // Unsorted list of integers with missing numbers + var intList = new List + { + // group #3: number 7 missing + 10, + 9, + 8, + // group #1 + 2, + 3, + 4, + // group #2: number 5 missing + 6 + }; + + var ranges = intList.ConsecutiveRanges().ToList(); + Assert.Multiple(() => + { + Assert.That(ranges, Has.Count.EqualTo(3)); + Assert.That(ranges[0], Is.EqualTo((2, 4))); + Assert.That(ranges[1], Is.EqualTo((6, 6))); + Assert.That(ranges[2], Is.EqualTo((8, 10))); + }); + } + + [Test] + public void DateOnlyRanges_ShouldBeConsecutive() + { + // Unsorted list of DateTime with missing dates + var dateOnlyList = new List + { + // group #3: number 7 Oct missing + new (2024, 10, 10), + new (2024, 10, 9), + new (2024, 10, 8), + // group #1 + new (2024, 10, 2), + new (2024, 10, 3), + new (2024, 10, 4), + // group #2: number 5 Oct missing + new (2024, 10, 6) + }; + + var ranges = dateOnlyList.ConsecutiveRanges().ToList(); + Assert.Multiple(() => + { + Assert.That(ranges, Has.Count.EqualTo(3)); + Assert.That(ranges[0], Is.EqualTo((new DateOnly(2024, 10, 2), new DateOnly(2024, 10, 4)))); + Assert.That(ranges[1], Is.EqualTo((new DateOnly(2024, 10, 6), new DateOnly(2024, 10, 6)))); + Assert.That(ranges[2], Is.EqualTo((new DateOnly(2024, 10, 8), new DateOnly(2024, 10, 10)))); + }); + } +} + + diff --git a/TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/GermanHolidayImporterTests.cs b/TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/GermanHolidayImporterTests.cs index 3ad641eb..2822fc9a 100644 --- a/TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/GermanHolidayImporterTests.cs +++ b/TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/GermanHolidayImporterTests.cs @@ -17,8 +17,10 @@ public void Import_HolidaysInAllFederalStates(DateTime from, DateTime to, int ex // using CET as time zone var tzConverter = new Axuno.Tools.DateAndTime.TimeZoneConverter( "Europe/Berlin", CultureInfo.CurrentCulture); - var holidayFilter = new Predicate(h => h.Type == GermanHolidays.Type.Public && h.PublicHolidayStateIds.Count == new GermanFederalStates().Count); - var hImporter = new GermanHolidayImporter(null, holidayFilter, tzConverter, NullLogger.Instance); + var holidayFilter = new Predicate(h => + h.Type == GermanHolidays.Type.Public && h.PublicHolidayStateIds.Count == new GermanFederalStates().Count); + var hImporter = + new GermanHolidayImporter(holidayFilter, tzConverter, NullLogger.Instance); var imported = hImporter.Import(new DateTimePeriod(from, to)).ToList(); @@ -33,8 +35,10 @@ public void Import_HolidaysInBavaria(DateTime from, DateTime to, int expectedCou // using CET as time zone var tzConverter = new Axuno.Tools.DateAndTime.TimeZoneConverter( "Europe/Berlin", CultureInfo.CurrentCulture); - var holidayFilter = new Predicate(h => h.Type == GermanHolidays.Type.Public && h.PublicHolidayStateIds.Contains(GermanFederalStates.Id.Bayern)); - var hImporter = new GermanHolidayImporter(null, holidayFilter, tzConverter, NullLogger.Instance); + var holidayFilter = new Predicate(h => + h.Type == GermanHolidays.Type.Public && h.PublicHolidayStateIds.Contains(GermanFederalStates.Id.Bayern)); + var hImporter = + new GermanHolidayImporter(holidayFilter, tzConverter, NullLogger.Instance); var imported = hImporter.Import(new DateTimePeriod(from, to)).ToList(); @@ -52,34 +56,66 @@ public void Import_Holidays_Volleyball_League_Augsburg(DateTime from, DateTime t var holidayFilter = new Predicate( h => h.Type == GermanHolidays.Type.Public && - h.PublicHolidayStateIds.Contains(GermanFederalStates.Id.Bayern) + h.PublicHolidayStateIds.Contains(GermanFederalStates.Id.Bayern) // add 5 more local holidays || h.Id == GermanHolidays.Id.AugsburgerFriedensfest || h.Id == GermanHolidays.Id.HeiligerAbend || - h.Id == GermanHolidays.Id.RosenMontag || h.Id == GermanHolidays.Id.FaschingsDienstag || - h.Id == GermanHolidays.Id.Silvester); + h.Id == GermanHolidays.Id.RosenMontag || h.Id == GermanHolidays.Id.FaschingsDienstag || + h.Id == GermanHolidays.Id.Silvester); - var hImporter = new GermanHolidayImporter(null, holidayFilter, tzConverter, NullLogger.Instance); + var hImporter = + new GermanHolidayImporter(holidayFilter, tzConverter, NullLogger.Instance); var imported = hImporter.Import(new DateTimePeriod(from, to)).ToList(); Assert.That(imported, Has.Count.EqualTo(expectedCount)); } - [Ignore("Tests fails and requires refactoring of GermanHolidays", Until = "2024-09-30")] - [TestCase("2019-09-01", "2020-06-30", 9)] + + [TestCase("2019-09-01", "2020-06-30", 6)] public void Import_With_Custom_School_Holidays(DateTime from, DateTime to, int expectedCount) { // Note: Custom_Holidays_Sample.xml contains 6 school holidays. // But as the command for them is "Merge", existing holidays persist unchanged, // and additional holiday periods are added. - var customHolidayFilePath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Assets", "Custom_Holidays_Sample.xml"); + var customHolidayFilePath = + Path.Combine(TestContext.CurrentContext.TestDirectory, "Assets", "Custom_Holidays_Sample.xml"); // using CET as time zone var tzConverter = new Axuno.Tools.DateAndTime.TimeZoneConverter( "Europe/Berlin", CultureInfo.CurrentCulture); + var holidayFilter = new Predicate(h => h.Type == GermanHolidays.Type.School); - var hImporter = new GermanHolidayImporter(customHolidayFilePath, holidayFilter, tzConverter, NullLogger.Instance); + var hImporter = new GermanHolidayImporter(customHolidayFilePath, holidayFilter, tzConverter, + NullLogger.Instance); + // The period from 2019-09-01 to 2020-06-30 contains 6 imported school holidays. var imported = hImporter.Import(new DateTimePeriod(from, to)).ToList(); Assert.That(imported, Has.Count.EqualTo(expectedCount)); } + + [Test] + public void Add_And_Remove_Holiday() + { + // Note: Custom_Holidays_Sample.xml contains 6 school holidays. + // But as the command for them is "Merge", existing holidays persist unchanged, + // and additional holiday periods are added. + var addHolidayFilePath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Assets", "Single_Holiday_To_Add.xml"); + var removeHolidayFilePath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Assets", "Single_Holiday_To_Remove.xml"); + // using CET as time zone + var tzConverter = new Axuno.Tools.DateAndTime.TimeZoneConverter( + "Europe/Berlin", CultureInfo.CurrentCulture); + + var holidayFilter = new Predicate(h => h.Type == GermanHolidays.Type.Public); + var hImporter = new GermanHolidayImporter(new[] { removeHolidayFilePath, addHolidayFilePath }, holidayFilter, + tzConverter, NullLogger.Instance); + + var imported = hImporter.Import(new DateTimePeriod(new DateTime(2024, 1, 1), new DateTime(2024, 1, 1))).ToList(); + + + Assert.Multiple(() => + { + Assert.That(imported, Has.Count.EqualTo(1)); + Assert.That(imported[0].Reason, Is.EqualTo("Sample New Year")); + }); + + } } diff --git a/TournamentManager/TournamentManager.Tests/TournamentManager.Tests.csproj b/TournamentManager/TournamentManager.Tests/TournamentManager.Tests.csproj index a4e8a002..8f995bcf 100644 --- a/TournamentManager/TournamentManager.Tests/TournamentManager.Tests.csproj +++ b/TournamentManager/TournamentManager.Tests/TournamentManager.Tests.csproj @@ -22,6 +22,12 @@ + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/TournamentManager/TournamentManager/Importers/ExcludeDates/EnumberableExtensions.cs b/TournamentManager/TournamentManager/Importers/ExcludeDates/EnumberableExtensions.cs deleted file mode 100644 index 2e2f4196..00000000 --- a/TournamentManager/TournamentManager/Importers/ExcludeDates/EnumberableExtensions.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace TournamentManager.Importers.ExcludeDates; - -public static class EnumerableValueTupleExtensions -{ - public static IEnumerable<(int First, int Last)> ConsecutiveRanges(this IEnumerable source) - { - using var e = source.GetEnumerator(); - for (var more = e.MoveNext(); more;) - { - int first = e.Current, last = first, next; - while ((more = e.MoveNext()) && (next = e.Current) > last && next - last == 1) - last = next; - yield return (first, last); - } - } - - public static IEnumerable<(DateTime First, DateTime Last)> ConsecutiveRanges(this IEnumerable source) - { - using var e = source.OrderBy(s => s).GetEnumerator(); - for (var more = e.MoveNext(); more;) - { - DateTime first = e.Current, last = first, next; - while ((more = e.MoveNext()) && (next = e.Current) > last && (next - last).Days == 1) - last = next; - yield return (first, last); - } - } - - /// - /// Creates a collection of s with consecutive date ranges having the same holiday name. - /// - /// An collection of . - /// Returns an of s with consecutive date ranges having the same holiday name. - public static IEnumerable<(DateTime From, DateTime To, string Name)> ConsecutiveRanges(this IEnumerable source) - { - // ensure the required order the holiday list - using var e = source.OrderBy(holiday => holiday.Date) - .ThenBy(holiday => holiday.Name).GetEnumerator(); - - for (var more = e.MoveNext(); more;) - { - if (e.Current is null) continue; - - var days = 1; - Axuno.Tools.GermanHoliday first = e.Current, last = first, next; - while ((more = e.MoveNext() && e.Current is not null) && - (next = e.Current!).Date > last.Date && - (ReferenceEquals(first, next) || (next.Date - first.Date).Days == days++ && next.Name == first.Name)) - last = next; - yield return (first.Date, last.Date, first.Name); - } - } -} diff --git a/TournamentManager/TournamentManager/Importers/ExcludeDates/EnumerableRangeExtensions.cs b/TournamentManager/TournamentManager/Importers/ExcludeDates/EnumerableRangeExtensions.cs new file mode 100644 index 00000000..4c379029 --- /dev/null +++ b/TournamentManager/TournamentManager/Importers/ExcludeDates/EnumerableRangeExtensions.cs @@ -0,0 +1,96 @@ +namespace TournamentManager.Importers.ExcludeDates; + +/// +/// The class provides extension methods for collections. +/// It is used to create a collection of s with consecutive value ranges. +/// +public static class EnumerableRangeExtensions +{ + /// + /// Creates a collection of s with consecutive ranges. + /// + /// + /// A collection of s with consecutive ranges. + public static IEnumerable<(int First, int Last)> ConsecutiveRanges(this IEnumerable source) + { + return source.Select(i => (long)i).ConsecutiveRanges().Select(range => ((int)range.First, (int)range.Last)); + } + + /// + /// Creates a collection of s with consecutive ranges. + /// + /// + /// A collection of s with consecutive ranges. + public static IEnumerable<(long First, long Last)> ConsecutiveRanges(this IEnumerable source) + { + using var e = source.OrderBy(i => i).GetEnumerator(); + for (var more = e.MoveNext(); more;) + { + long first = e.Current, last = first, next; + while ((more = e.MoveNext()) && (next = e.Current) > last && next - last == 1) + last = next; + yield return (first, last); + } + } + + /// + /// Creates a collection of s with consecutive ranges. + /// + /// + /// A collection of s with consecutive ranges. + public static IEnumerable<(DateOnly First, DateOnly Last)> ConsecutiveRanges(this IEnumerable source) + { + using var e = source.OrderBy(s => s).GetEnumerator(); + for (var more = e.MoveNext(); more;) + { + DateOnly first = e.Current, last = first, next; + while ((more = e.MoveNext()) && (next = e.Current) > last && (next.DayNumber - last.DayNumber) == 1) + last = next; + yield return (first, last); + } + } + + /// + /// Creates a collection of s with consecutive date ranges having the same holiday name. + /// Only the date part of the is considered. + /// + /// An collection of . + /// Returns an of s with consecutive date ranges having the same holiday name. + public static IEnumerable<(DateTime From, DateTime To, string Name)> ConsecutiveRanges(this IEnumerable source) + { + // ensure the required order the holiday list + using var e = source.OrderBy(holiday => holiday.Date) + .ThenBy(holiday => holiday.Name).GetEnumerator(); + + DateTime? first = null, last = null; + var name = string.Empty; + + while (e.MoveNext()) + { + var date = e.Current; + + if (first is null) + { + first = date.Date; + last = date.Date; + name = date.Name; + } + else if (date.Date > last!.Value.Date.AddDays(1) || date.Name != name) + { + yield return (first.Value.Date, last.Value.Date, name); + first = date.Date; + last = date.Date; + name = date.Name; + } + else + { + last = date.Date; + } + } + + if (first is not null) + { + yield return (first.Value, last!.Value, name); + } + } +} diff --git a/TournamentManager/TournamentManager/Importers/ExcludeDates/GermanHolidayImporter.cs b/TournamentManager/TournamentManager/Importers/ExcludeDates/GermanHolidayImporter.cs index 1733e378..77e2544f 100644 --- a/TournamentManager/TournamentManager/Importers/ExcludeDates/GermanHolidayImporter.cs +++ b/TournamentManager/TournamentManager/Importers/ExcludeDates/GermanHolidayImporter.cs @@ -1,23 +1,38 @@ -using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; namespace TournamentManager.Importers.ExcludeDates; public class GermanHolidayImporter : IExcludeDateImporter { - private readonly string? _specialHolidaysXmlFile; + private readonly ICollection _specialHolidaysXmlFiles; private readonly Predicate _holidayFilter; private readonly Axuno.Tools.DateAndTime.TimeZoneConverter _timeZoneConverter; private readonly ILogger _logger; - public GermanHolidayImporter(string? specialHolidaysXmlFile, Predicate holidayFilter, Axuno.Tools.DateAndTime.TimeZoneConverter timeZoneConverter, + public GermanHolidayImporter(Predicate holidayFilter, Axuno.Tools.DateAndTime.TimeZoneConverter timeZoneConverter, ILogger logger) { - _specialHolidaysXmlFile = specialHolidaysXmlFile; + _specialHolidaysXmlFiles = new Collection(); _holidayFilter = holidayFilter; _timeZoneConverter = timeZoneConverter; _logger = logger; } + public GermanHolidayImporter(ICollection specialHolidaysXmlFiles, Predicate holidayFilter, Axuno.Tools.DateAndTime.TimeZoneConverter timeZoneConverter, + ILogger logger) : this(holidayFilter, timeZoneConverter, logger) + { + _specialHolidaysXmlFiles = specialHolidaysXmlFiles; + } + + public GermanHolidayImporter(string specialHolidaysXmlFile, Predicate holidayFilter, Axuno.Tools.DateAndTime.TimeZoneConverter timeZoneConverter, + ILogger logger) : this(holidayFilter, timeZoneConverter, logger) + { + _specialHolidaysXmlFiles = string.IsNullOrEmpty(specialHolidaysXmlFile) + ? Array.Empty() + : new Collection { specialHolidaysXmlFile }; + } + public IEnumerable Import(DateTimePeriod fromToTimePeriod) { if (fromToTimePeriod is not { Start: not null, End: not null }) @@ -42,11 +57,11 @@ public IEnumerable Import(DateTimePeriod fromToTimePeriod) // Loading custom holidays expects that German holiday are already in the list, // because otherwise the "Replace" command will not succeed - if (!string.IsNullOrEmpty(_specialHolidaysXmlFile)) + foreach (var file in _specialHolidaysXmlFiles) { // The holidays file must be imported **for each year** - currentYearHolidays.Load(_specialHolidaysXmlFile); - _logger.LogDebug("Holidays from file '{Filename}' loaded. Now counts {HolidaysCount} for year {Year}", _specialHolidaysXmlFile, currentYearHolidays.Count, currentYear); + currentYearHolidays.Load(file); + _logger.LogDebug("Holidays from file '{Filename}' loaded. Now counts {HolidaysCount} for year {Year}", _specialHolidaysXmlFiles, currentYearHolidays.Count, currentYear); } holidays.AddRange(currentYearHolidays.GetFiltered(_holidayFilter)); @@ -58,9 +73,11 @@ public IEnumerable Import(DateTimePeriod fromToTimePeriod) private IEnumerable Map(List holidays, DateTimePeriod dateLimits) { - // sort short date ranges before big ranges - var holidayGroups = holidays.ConsecutiveRanges() - .OrderBy(tuple => tuple.From.Date).ThenBy(tuple => (tuple.To - tuple.From).Days); + // sort short date ranges before big ranges after consecutive ranges are calculated + var holidayGroups = + holidays.ConsecutiveRanges() + .OrderBy(tuple => tuple.From.Date) + .ThenBy(tuple => (tuple.To - tuple.From).Days); foreach (var holidayGroup in holidayGroups) {