确定间隔的哪一部分与给定时区的给定工作日重叠

Determine which part of an interval overlaps a given weekday in a given time zone

我有一个全球时间间隔(从一个 UTC "time stamp" 到另一个),想确定时间间隔的哪一部分与给定时区的给定工作日重叠。

举个例子:假设时间间隔是2018-05-11T02:00:00Z/2018-05-11T10:00:00Z,星期几是星期五

对于纽约 (America/New_York),时间间隔转换为本地日期时间间隔 2018-05-10T22:00/2018-05-11T06:00,其中时间间隔的前两个小时不与星期五重叠。因此,结果间隔应为 2018-05-11T04:00:00Z/2018-05-11T10:00:00Z。 如果时区是哥本哈根 (Europe/Copenhagen),则原始间隔将保持不变,因为所有时间都与该时区的星期五重叠。

如果间隔足够长,您很容易与工作日有多个重叠。完全不重叠当然也是有可能的。

我很难找到解决这个问题的最佳和最可靠的方法。我最好的想法是把星期几从给定的时区翻译成全球时间,然后检查重叠。但是,星期几并没有固定到特定日期,这意味着我真的没有什么要翻译的,因此首先必须弄清楚星期几可能是哪些重叠日期。

如果我将时间间隔转换为当地时间,与星期几重叠(更容易,因为我现在有实际日期可以使用)然后将它们转换回来,我会在大多数情况下得到正确答案个案。但是,夏令时转换之类的东西很容易把事情搞砸,当翻译回来的时候,回退可能会导致本地日期时间间隔"invalid",即开始在结束之后,打开另一个蠕虫罐头。

我正在尝试使用 NodaTime 解决 C# 中的问题,但认为该问题是一般性问题。

下面是@jskeet 要求的几个测试用例:

using FluentAssertions;
using NodaTime;
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;

public class Tests
{
    public static TheoryData<Interval, IsoDayOfWeek, DateTimeZone, IEnumerable<Interval>> OverlapsDayOfWeekExamples = new TheoryData<Interval, IsoDayOfWeek, DateTimeZone, IEnumerable<Interval>>
    {
        {   // No overlap in given time zone
            new Interval(Instant.FromUtc(2018, 05, 11, 00, 00), Instant.FromUtc(2018, 05, 11, 04, 00)),
            IsoDayOfWeek.Friday,
            DateTimeZoneProviders.Tzdb["America/New_York"],
            Enumerable.Empty<Interval>()
        },
        {   // Cut short because interval begins Thursday
            new Interval(Instant.FromUtc(2018, 05, 11, 02, 00), Instant.FromUtc(2018, 05, 11, 10, 00)),
            IsoDayOfWeek.Friday,
            DateTimeZoneProviders.Tzdb["America/New_York"],
            new [] { new Interval(Instant.FromUtc(2018, 05, 11, 04, 00), Instant.FromUtc(2018, 05, 11, 10, 00)) }
        },
        {   // Remains unchanged because everything overlaps in given time zone
            new Interval(Instant.FromUtc(2018, 05, 11, 02, 00), Instant.FromUtc(2018, 05, 11, 10, 00)),
            IsoDayOfWeek.Friday,
            DateTimeZoneProviders.Tzdb["Europe/Copenhagen"],
            new [] { new Interval(Instant.FromUtc(2018, 05, 11, 02, 00), Instant.FromUtc(2018, 05, 11, 10, 00)) }
        },
        {   // Cut short because interval begins Saturday and day starts at 01:00 (Spring forward)
            new Interval(Instant.FromUtc(2018, 11, 04, 02, 15), Instant.FromUtc(2018, 11, 04, 06, 30)),
            IsoDayOfWeek.Sunday,
            DateTimeZoneProviders.Tzdb["America/Sao_Paulo"],
            new [] { new Interval(Instant.FromUtc(2018, 11, 04, 03, 00), Instant.FromUtc(2018, 11, 04, 06, 30)) }
        },
        {   // Cut short because interval begins Saturday and day starts later (Fall back)
            new Interval(Instant.FromUtc(2018, 02, 18, 01, 00), Instant.FromUtc(2018, 02, 18, 07, 30)),
            IsoDayOfWeek.Sunday,
            DateTimeZoneProviders.Tzdb["America/Sao_Paulo"],
            new [] { new Interval(Instant.FromUtc(2018, 02, 18, 03, 00), Instant.FromUtc(2018, 02, 18, 07, 30)) }
        },
        {   // Overlaps multiple times (middle overlap is during DST transition)
            new Interval(Instant.FromUtc(2018, 10, 28, 16, 15), Instant.FromUtc(2018, 11, 11, 12, 30)),
            IsoDayOfWeek.Sunday,
            DateTimeZoneProviders.Tzdb["America/New_York"],
            new []
            {
                new Interval(Instant.FromUtc(2018, 10, 28, 16, 15), Instant.FromUtc(2018, 10, 29, 04, 00)),
                new Interval(Instant.FromUtc(2018, 11, 04, 04, 00), Instant.FromUtc(2018, 11, 05, 05, 00)),
                new Interval(Instant.FromUtc(2018, 11, 11, 05, 00), Instant.FromUtc(2018, 11, 11, 12, 30)),
            }
        },
        {   // Results in an invalid date time interval
            new Interval(Instant.FromUtc(2018, 10, 28, 00, 45), Instant.FromUtc(2018, 10, 28, 01, 15)),
            IsoDayOfWeek.Sunday,
            DateTimeZoneProviders.Tzdb["Europe/Copenhagen"],
            new [] { new Interval(Instant.FromUtc(2018, 10, 28, 00, 45), Instant.FromUtc(2018, 10, 28, 01, 15)) }
        },
    };

    [Theory]
    [MemberData(nameof(OverlapsDayOfWeekExamples))]
    public void OverlapsDayOfWeekTest531804504(Interval interval, IsoDayOfWeek dayOfWeek, DateTimeZone timeZone, IEnumerable<Interval> expected)
    {
        OverlapsDayOfWeek(interval, dayOfWeek, timeZone).Should().BeEquivalentTo(expected);
    }

    public IEnumerable<Interval> OverlapsDayOfWeek(Interval interval, IsoDayOfWeek dayOfWeek, DateTimeZone timeZone)
    {
        throw new NotImplementedException();
    }
}

测试中存在一些问题,但稍作更改后,它们通过了以下代码。原则上,这是一个问题:

  • 取间隔,计算出可能在任何时区内的日期。我们可以将它转换为 UTC 中的日期间隔,并在每个方向上将其扩展几天。输出:日期序列。
  • 对于该日期序列中的每个日期,将其转换为目标区域中的间隔:开始是该区域中一天的开始;结束是该区域第二天的开始。 (这将处理 DST 转换。)输出:一系列间隔。
  • 对于该间隔序列中的每个间隔,确定它与输入间隔之间的交集。输出:一系列间隔,其中一些可能为空(对于"no intersection")
  • 结果是序列中的非空区间。

这里的代码证明了这一点:

using System;
using System.Collections.Generic;
using System.Linq;
using NodaTime;

public class Program 
{
    public static void Main() 
    {
        var start = Instant.FromUtc(2018, 5, 11, 2, 0);
        var end = Instant.FromUtc(2018, 5, 11, 10, 0);
        var input = new Interval(start, end);

        DisplayDayIntervals(input, "America/New_York", IsoDayOfWeek.Friday);
        DisplayDayIntervals(input, "Europe/Copenhagen", IsoDayOfWeek.Friday);
    }

    static void DisplayDayIntervals(Interval input, string zoneId, IsoDayOfWeek dayOfWeek)
    {
        var zone = DateTimeZoneProviders.Tzdb[zoneId];
        var intervals = GetDayIntervals(input, zone, dayOfWeek);
        Console.WriteLine($"{zoneId}: [{string.Join(", ", intervals)}]");
    }

    public static IEnumerable<Interval> GetDayIntervals(
        Interval input,
        DateTimeZone zone,
        IsoDayOfWeek dayOfWeek)
    {
        // Get a range of dates that covers the input interval. This is deliberately
        // larger than it may need to be, to handle days starting at different instants
        // in different time zones. 
        LocalDate startDate = input.Start.InZone(DateTimeZone.Utc).Date.PlusDays(-2);
        LocalDate endDate = input.End.InZone(DateTimeZone.Utc).Date.PlusDays(2);        
        var dates = GetDates(startDate, endDate, dayOfWeek);

        // Convert those dates into intervals, each of which may or may not overlap
        // with our input.
        var intervals = dates.Select(date => GetIntervalForDate(date, zone));

        // Find the intersection of each date-interval with our input, and discard
        // any non-overlaps
        return intervals.Select(dateInterval => Intersect(dateInterval, input))
                        .Where(x => x != null)
                        .Select(x => x.Value);
    }

    private static IEnumerable<LocalDate> GetDates(LocalDate start, LocalDate end, IsoDayOfWeek dayOfWeek)
    {
        for (var date = start.With(DateAdjusters.NextOrSame(dayOfWeek));
             date <= end;
             date = date.With(DateAdjusters.Next(dayOfWeek)))
         {
             yield return date;
         }
    }

    private static Interval GetIntervalForDate(LocalDate date, DateTimeZone zone)
    {
        var start = date.AtStartOfDayInZone(zone).ToInstant();
        var end = date.PlusDays(1).AtStartOfDayInZone(zone).ToInstant();
        return new Interval(start, end);
    }

    private static Interval? Intersect(Interval left, Interval right)
    {
        Instant start = Instant.Max(left.Start, right.Start);
        Instant end = Instant.Min(left.End, right.End);
        return start < end ? new Interval(start, end) : (Interval?) null;
    }
}