0

An app has a feature that's much like any calendar application (like the Outlook calendar for example). Consequently, I need to do a lot of date logic. I wrote a class with date calculation methods that are useful in any scenario.

You can choose in what resolution of time you want to view the calendar, like a whole month, a week, a workweek, or a day.

Each calendar view corresponds to a date range. If you view the calendar by weeks, everytime you browse to a given week or navigate to the previous/next week, a class generates a date range containing the start and end DateTime of the visible period in time.

For workweek resolution, this means the saturday and sunday are skipped and so the date ranges are not adjacent and don't "touch" each other.

The same principle applies to the month view. The start of the visible date range is not just the first day of the month. If the month's first day is not a monday, a few days of the previous month are shown too, and are part of the visible date range. The same kind of logic applies for the end of the month.

My plan was to create classes that function as date range generators. For each type of calendar view a specific date range generator class knows how to create the asked period of time.

The interface:

/// <summary>
/// Represents a class that generates date ranges for loading appointmens
/// of an agenda.
/// </summary>
public interface IAgendaDateRangeGenerator
{
    /// <summary>
    /// Generates the date range that contains the given date.
    /// 
    /// Example case: when the class should generate date ranges of each week in the year,
    /// this method would generate a date range starting at monday and ending at sunday with 
    /// the given date within the date range. In other words, the generator creates the week of 
    /// the year that contains the given date.
    /// </summary>
    /// <param name="date"></param>
    /// <returns></returns>
    DateRange DateRangeForDay(DateTime date);

    /// <summary>
    /// Generates the previous period of time.
    /// </summary>
    /// <param name="dateRange"></param>
    /// <returns></returns>
    DateRange PreviousRange(DateRange dateRange);

    /// <summary>
    /// Generates the next period of time.
    /// </summary>
    /// <param name="dateRange"></param>
    /// <returns></returns>
    DateRange NextRange(DateRange dateRange);
}

The date helper class containing date calculation logic (mainly for context/part of review):

public class DateHelper
{
    public static DateTime EndOfDay(DateTime date)
    {
        return new DateTime(date.Year, date.Month, date.Day, 23, 59, 59, 999);
    }

    public static DateTime BeginOfDay(DateTime date)
    {
        return date.Date; // By returning date component, the time component will be zero values.
    }

    public static DateTime BeginOfWeekContaining(DateTime date)
    {
        int diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
        return date.AddDays(-1 * diff);
    }

    public static DateTime EndOfWeekContaining(DateTime date)
    {
        DateTime monday = BeginOfWeekContaining(date);
        return monday.AddDays(6);
    }

    /// <summary>
    /// Gives the first date in future that's on the given DayOfWeek.
    /// Example, if the given date is a tuesday and the next following saturday is wanted
    /// this method returns the date of that saturday.
    /// Does not change time component.
    /// </summary>
    /// <param name="date"></param>
    /// <param name="dayOfWeek"></param>
    /// <returns></returns>
    public static DateTime FindFirstNextDay(DateTime date, DayOfWeek wantedDayOfWeek)
    {
        do
        {
            date = date.AddDays(1);
        }
        while (date.DayOfWeek != wantedDayOfWeek);
        return date;
    }

    /// <summary>
    /// Opposite of method FindFirstNextDay
    /// Looks in history until it finds the date occurence with the given DayOfWeek.
    /// Does not change time component.
    /// </summary>
    /// <param name="date"></param>
    /// <param name="wantedDayOfWeek"></param>
    /// <returns></returns>
    public static DateTime FindFirstPreviousDay(DateTime date, DayOfWeek wantedDayOfWeek)
    {
        do
        {
            date = date.AddDays(-1);
        }
        while (date.DayOfWeek != wantedDayOfWeek);
        return date;
    }

    /// <summary>
    /// Returns the date of the first day of the month that contains the given date.
    /// </summary>
    /// <param name="date"></param>
    /// <returns></returns>
    public static DateTime BeginOfMonthContaining(DateTime date)
    {
        int month = date.Month;
        while (date.AddDays(-1).Month == month)
        {
            date = date.AddDays(-1);
        }
        return date;
    }

    /// <summary>
    /// Returns date of last day of the month that contains the given date.
    /// </summary>
    /// <param name="date"></param>
    /// <returns></returns>
    public static DateTime EndOfMonthContaining(DateTime date)
    {
        int month = date.Month;
        while(date.AddDays(1).Month == month)
        {
            date = date.AddDays(1);
        }
        return date;
    }
}

Example of date range generator for workweeks (monday-friday):

public class WorkWeekDateRangeGenerator : IAgendaDateRangeGenerator
{
    public DateRange DateRangeForDay(DateTime date)
    {
        DateTime monday = DateHelper.BeginOfWeekContaining(date);
        DateTime friday = DateHelper.FindFirstNextDay(monday, DayOfWeek.Friday);
        return new DateRange(monday, friday);
    }

    public DateRange PreviousRange(DateRange fromRange)
    {
        DateTime start = DateHelper.FindFirstPreviousDay(fromRange.StartDate, DayOfWeek.Monday);
        DateTime end = DateHelper.FindFirstPreviousDay(fromRange.StartDate, DayOfWeek.Friday);
        return new DateRange(start, end);
    }

    public DateRange NextRange(DateRange fromRange)
    {
        DateTime start = DateHelper.FindFirstNextDay(fromRange.EndDate, DayOfWeek.Monday);
        DateTime end = DateHelper.FindFirstNextDay(fromRange.EndDate, DayOfWeek.Friday);
        return new DateRange(start, end);
    }
}

As you can see, the interface is designed to be stateless. Which means the generator classes's PreviousRange and NextRange methods get a reference date range for determining the previous/next period. The class doesn't have to keep track of the current period. The outside world that uses the class stores the current date range and passes it to the PreviousRange or NextRange method of the generator class instance when navigating.

My biggest question is wether it is wise to have a stateless design. Mainly because the periods of time are not necessarily adjacent (like in my workweek generator example). If the design wouldn't be stateless, I'd probably store a week number in the generator class (in the case of the workweek example). Then I use the week number to create the date ranges and increase/decrease the week number each time a next/previous period is requested. It would be more like a state-machine then. I'm looking forward to your review and possible improvements or maybe even a total different approach.

5
  • Note that the design of the generator does not have to match the design of the result. You seem to be arguing the former based on the latter.
    – Flater
    Jan 22, 2022 at 20:19
  • @Flater Could you explain a bit more, please? Jan 22, 2022 at 21:16
  • I see no reason to exclude the possibility of a static generator which returns a range result object that holds the relevant range you requested; but your question seems to imply it can only be one or the other.
    – Flater
    Jan 22, 2022 at 23:26
  • 1
    I'd leave this stateless, and have its only responsibilities be what they are now. It keeps the code clean and reusable. If you want/need stateful logic, build it on top of this (create a new thing that uses your generator internally, or has it as an injected dependency). The interface of your generator should make the code of this new component simpler: it lets it focus on enforcing consistent behavior and application rules, and delegate all that date-fiddling to the generator. Jan 23, 2022 at 13:54
  • It sounds like you want to add state to reduce the amount of computation required when the caller is asking for a series of adjacent ranges. Why not write a method specifically for getting a series of adjacent ranges with one call? Then you could keep the week number as a local variable.
    – John Wu
    Jan 26, 2022 at 18:08

2 Answers 2

1

When designing an API it is useful to consider how it is supposed to be used, and how likely it is to be used incorrectly. For example What is your previous/next methods supposed to return for a completely unexpected range, like a complete year? As far as I can tell from your examples, this would return the last work-week of the year, and that might or might not be what was expected. It is usually good to try to minimize the possibility of such errors when possible.

There could be several options to avoid this

  1. Introduce a state to your IAgendaDateRangeGenerator, making Next/previous change the internal state. But this has its own issues.
  2. You could use Curiously reoccurring template pattern to produce sequences of the same type. I.e.
public interface IDateRange<T> where T : IDateRange<T>{
    T Next();
    DateTime Start {get;}
    DateTime  End {get;}
}
public class WorkWeekRange : IDateRange<WorkWeekRange>{
    public WorkWeekRange Next() => ...
    public DateTime Start {get;}
    public DateTime  End {get;}
}
  1. Make DateRangeForDay return a new type that has a indexer, where zero is the given date, and allow forward or backward indexing. Possibly also exposing an IEnumerable for forward and backward iteration.
1

On one end of the spectrum, you could have individual stateless generators per range type. On the other end of the spectrum, you could have a single generator which can account for all possible range types.

Where on the spectrum the appropriate solution is found depends on considerations such as how much there can be meaningfully reused between the different generator approaches. If the only reusable part is that you iterate every day from start to end with no custom behavior for given date range types, that doesn't particularly sound like a meaningful enough abstraction to me.

That being said, this date iteration could be more complex, e.g. accounting for bank holidays, custom weekend definitions, which is the start of the week, ... All of these things impact your logic, but it is unclear whether this is configurable (either on application level or user level) in your scenario.

For the sake of example, I've assumed that you want to be able to override this logic. An example of this would be a month range that only shows week days.

The start of the visible date range is not just the first day of the month. If the month's first day is not a monday, a few days of the previous month are shown too

In the image you provided, the "extra" days are the start/end of the month are not actually shown. You're shown an empty box; the date is not being shown (nor presumably the day content).

This distinction matters. If it's an empty block, then adding these blocks is a UI consideration, not one of business logic. If it's a UI consideration, the generation logic should not account for it directly.

In this answer, I've assumed you need those days in the range. But if they are supposed to be empty blocks, I suggest you implement this on the UI level, not the logic level.


At a basic level, since you want to be able to polymorphically address these ranges, it makes sense to start from an abstract base class.
Note that I'm opting for a base class and not an interface here, so you can provide some shared logic. As mentioned above, whether or not that shared logic is needed depends on how much of it there is. If it's not needed, then you can simply use an interface instead of a base class.

public abstract class DateRange<T> where T : DateRange<T>
{
    private readonly List<DateTime> values;
    public IReadOnlyCollection<DateTime> Values => values;

    public DateTime Start { get; private set; }
    public DateTime End { get; private set; }

    public DateRange(DateTime targetDate)
    {
        values = GenerateRange(targetDate.Date).ToList();
    }

    protected abstract DateTime CalculateStartOfRange(DateTime targetDate);
    protected abstract DateTime CalculateEndOfRange(DateTime targetDate);

    protected virtual IEnumerable<DateTime> GenerateRange(DateTime targetDate)
    {
        // Find the appropriate start/end for this range type
        this.Start = CalculateStartOfRange(targetDate);
        this.End = CalculateEndOfRange(targetDate);

        // Generate every day from the start to the end (inclusive)
        var cursor = this.Start;
        do
        {
            yield return cursor;
            cursor = cursor.AddDays(1);
        } while (cursor.Date <= this.End.Date);
    }

    public abstract T Previous();
    public abstract T Next();
}

Some explanation as to how it is intended to be used:

  • Since it is an abstract class, only derived classes can be used. This is by design as the DateRange class by itself is incomplete. The derived classes are expected to fill in the blanks.
  • Every DateRange has a start, end, and a list of all included values. These are stored and not generated repeatedly as a matter of performance when accessing the data multiple times.
  • CalculateStartOfRange/CalculateEndOfRange: The derived class must define how the start/end dates are found based on the target date. This is enforced by making the method abstract, the compiler will enforce these implementations of any class that derives from DateRange.
  • GenerateRange was made virtual so that it can be overridden in derived classes, but it is not necessary to do so. You only need to override it when you intend to deviate from the "every day between start and end" generation logic.
  • Previous/Next: The derived class must define how to find its adjacent ranges.
    • In the given example, the class was made generic so that the specific derived type can be used as a return type for these methods.
    • The generic type makes it difficult if your application wants to work with any arbitrary DateRange instance without needing to know the specific type. In such a case, you can omit the generic type, but then Previous() and Next() will have to have a return type of DateRange.

This sets you up for creating derived types for each calendar type.

public class WorkWeekRange : DateRange<WorkWeekRange>
{
    public WorkWeekRange(DateTime targetDate) : base(targetDate) { }

    protected override DateTime CalculateStartOfRange(DateTime targetDate)
    {
        // Find the first monday before (or on) the target date
    }

    protected override DateTime CalculateEndOfRange(DateTime targetDate)
    {
        // Find the first Friday after (or on) the target date
    }

    public override WorkWeekRange Previous()
    {
        return new WorkWeekRange(this.Start.AddDays(-7));
    }

    public override WorkWeekRange Next()
    {
        return new WorkWeekRange(this.Start.AddDays(7));
    }        
}

public class MonthRange : DateRange<WorkWeekRange>
{
    public MonthRange(DateTime targetDate) : base(targetDate) { }

    protected override DateTime CalculateStartOfRange(DateTime targetDate)
    {
        // Find the first monday before (or on) the 1st of the target date's month
    }

    protected override DateTime CalculateEndOfRange(DateTime targetDate)
    {
        // Find the first Sunday after (or on) the last of the target date's month
    }

    public override WorkWeekRange Previous()
    {
        return new WorkWeekRange(this.Start.AddMonths(-1));
    }

    public override WorkWeekRange Next()
    {
        return new WorkWeekRange(this.Start.AddMonths(1));
    }
}

I've glossed over the calculation logic here, but as you can see, once your DateRange class has been designed, the only thing you have left to implement for each range type is how to find the start/end/previous/next; everything else is already taken care of.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.