Is there a class for encoding a local time of week in Java?
Asked Answered
C

2

6

I'm wanting to create a schedule that wraps around week by week.

Because the schedule is the same from one week to the next, the only information I need to store is the day of the week, and the time it occurs. eg. Monday 2:30pm. The actual date isn't important, nor is time zone.

So far I've been writing my code with the day and time separate, using the DayOfWeek enum and the LocalTime type to work with times. But I've started to run into issues with using two variables to manage time instead of just a single convenient type like DateTime. eg. if I want to get a time 3 hours after 11pm on Tuesday, I can use the LocalTime.plus() method to get 2am, but this doesn't account for the day rollover, and I'd have to check and update that separately.

I also want to make sure that whatever solution I have wraps around from the end of week to the start: eg. 5hrs after Sunday 10pm should be Monday 3am.

Is there a class like this that exists in the JDK or is it easy enough to define your own time types? Or would it just be better to work something out using the LocalDateTime that java provides, and ignore the date component somehow?

Condescend answered 26/4, 2020 at 12:13 Comment(0)
H
6

There is no such built-in class within the JDK, but it should be not too hard to create such a class yourself.

I think you are on the good track with using LocalTime and DayOfWeek. Make sure to write a class which wraps these two components, and then add methods to add time units to the wrapper object.

One of the methods could look like something like this:

public WeekDayTime plusHours(int hours) {
    int plusDays = (hours / 24);
    int plusHours = hours % 24;
    if (this.time.getHours() + plusHours >= 24) {
        plusDays++;
        plusHours -= 24;
    }
    DayOfWeek newDayOfWeek = ...; // Calculate the day of week somehow
    LocalTime newTime = this.time.plusHours(plusHours);
    return new WeekDayTime(newDayOfWeek, newTime);
}

Alternatively, you could also wrap a LocalDateTime and just hide the date component. This will save you from implementing those calculations. But then make sure you implement for instance the equals method correctly.

Hutchens answered 26/4, 2020 at 12:32 Comment(3)
The incomplete code line is as simple as DayOfWeek newDayOfWeek = this.dayOfWeek.plus(plusDays);. DayOfWeek.plus() has cyclic overflow, so for example SATURDAY.plus(2) yields MONDAY.Coopt
@OleVV I wasn't sure about DayOfWeek having a plus method. Good to know. However, it's another story if the user want to include only certain days of the week, for example, only business days.Hutchens
Your good Answer inspired me to write such a class. Mine takes the more general Duration or Period rather than a number of hours. See my Answer.Martinmas
M
4

The correct Answer by MC Emperor inspired me to take the next step, devising a DayOfWeekTime class that more flexibly does date-time method by accepting the more general Period & Duration class objects rather than just a number of hours.

I leveraged the existing java.time classes as much as possible. I followed the naming conventions of java.time. And I emulated the functionality of java.time.

For strings, I looked to the ISO 8601 standard used by java.time. Unfortunately, the standard does not address the day-of-week with time-of-day concept. The standard does have an idea of week of year, noted with a W followed by a number from 1-52 or 1-53. For a particular day of a particular week, the standard append a hyphen with a number 1-7 for Monday-Sunday. So I followed that pattern for my toString and parse methods. I start with a W, omit any week number, follow with a hyphen and a day-of-week number 1-7. I append a T as a separator, following the lead of the standard. Then append the time-of-day in 24-hour clock with padding zero for hour and minute. For example, the date-time of 2020-04-27T13:00-04:00[America/Montreal] produces W-7T19:46:40.937485.

This code has been only barely tested. Use at your own risk.

package work.basil.example;

import java.time.*;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAdjusters;
import java.util.Arrays;
import java.util.Locale;
import java.util.Objects;

// Revised 2020-04-27. Fixed bug where the plus & minus methods adjusted from baseline by time-of-day but not day-of-week. Fixed.

public class DayOfWeekTime
{
    // ----------|  Fields  |------------------------------------------

    private DayOfWeek dayOfWeek;
    private LocalTime localTime;

    // ----------|  Statics  |------------------------------------------

    // We do the date-time math by picking a date arbitrarily to use as a `LocalDateTime`.
    // For convenience, we might as well pick a year that starts on a Monday.
    // https://en.wikipedia.org/wiki/Common_year_starting_on_Monday
    // Let us go with 2001-01-01.
    static private LocalDateTime BASELINE = LocalDateTime.of( 2001 , 1 , 1 , 0 , 0 , 0 , 0 );

    // ----------|  Constructor  |------------------------------------------

    private DayOfWeekTime ( final DayOfWeek dayOfWeek , final LocalTime localTime )
    {
        Objects.requireNonNull( dayOfWeek , "Received a null argument. Message # e3e04fde-d96a-41d8-a4e7-c2b4f2f5634b." );
        Objects.requireNonNull( localTime , "Received a null argument. Message # 97ccf27a-6c05-402a-a4aa-0a48bcff62c2." );
        this.dayOfWeek = dayOfWeek;
        this.localTime = localTime;
    }

    // ----------|  Factory  |------------------------------------------

    static public DayOfWeekTime of ( final DayOfWeek dayOfWeek , final LocalTime localTime )
    {
        Objects.requireNonNull( dayOfWeek , "Received a null argument. Message # ecfe6bf6-de34-4f63-9a3e-d04cd70e721f." );
        Objects.requireNonNull( localTime , "Received a null argument. Message # 83020094-409d-40e1-8dc3-12592eea1b81." );
        return new DayOfWeekTime( dayOfWeek , localTime );
    }

    static public DayOfWeekTime now ( ZoneId zoneId )
    {
        Objects.requireNonNull( zoneId , "Received null argument for time zone. Message # 6044dd82-3616-40a6-8ac2-52581e12e60f." );
        ZonedDateTime now = ZonedDateTime.now( zoneId );
        DayOfWeek dow = now.getDayOfWeek();
        LocalTime lt = now.toLocalTime();
        DayOfWeekTime dayOfWeekTime = DayOfWeekTime.of( dow , lt );
        return dayOfWeekTime;
    }

    // ----------|  Duration  |------------------------------------------
    public DayOfWeekTime plus ( final Duration duration )
    {
        Objects.requireNonNull( duration , "Received a null argument. Message # cf60bd16-3992-4779-a621-a0a3fdb2d750." );
        LocalDateTime ldt = DayOfWeekTime.BASELINE.with( TemporalAdjusters.nextOrSame( this.dayOfWeek ) ).with( this.localTime );
        LocalDateTime ldtSum = ldt.plus( duration );
        DayOfWeekTime dayOfWeekTime = DayOfWeekTime.of( ldtSum.getDayOfWeek() , ldtSum.toLocalTime() );
        return dayOfWeekTime;
    }

    public DayOfWeekTime minus ( final Duration duration )
    {
        Objects.requireNonNull( duration , "Received a null argument. Message # 4e7bf8c9-6e4f-4e3f-a8b1-5a42dc23cd8a." );
        LocalDateTime ldt = DayOfWeekTime.BASELINE.with( TemporalAdjusters.nextOrSame( this.dayOfWeek ) ).with( this.localTime );
        LocalDateTime ldtSum = ldt.minus( duration );
        DayOfWeekTime dayOfWeekTime = DayOfWeekTime.of( ldtSum.getDayOfWeek() , ldtSum.toLocalTime() );
        return dayOfWeekTime;
    }

    // ----------|  Period  |------------------------------------------

    public DayOfWeekTime plus ( final Period period )
    {
        Objects.requireNonNull( period , "Received a null argument. Message # 3b1f65b0-5b2c-4e86-aaa3-527992356d32." );
        LocalDateTime ldt = DayOfWeekTime.BASELINE.with( TemporalAdjusters.nextOrSame( this.dayOfWeek ) ).with( this.localTime );
        LocalDateTime ldtSum = ldt.plus( period );
        DayOfWeekTime dayOfWeekTime = DayOfWeekTime.of( ldtSum.getDayOfWeek() , ldtSum.toLocalTime() );
        return dayOfWeekTime;
    }

    public DayOfWeekTime minus ( final Period period )
    {
        Objects.requireNonNull( period , "Received a null argument. Message # 045938fc-d4b2-4bd2-8803-91db54d92564." );
        LocalDateTime ldt = DayOfWeekTime.BASELINE.with( TemporalAdjusters.nextOrSame( this.dayOfWeek ) ).with( this.localTime );
        LocalDateTime ldtSum = ldt.minus( period );
        DayOfWeekTime dayOfWeekTime = DayOfWeekTime.of( ldtSum.getDayOfWeek() , ldtSum.toLocalTime() );
        return dayOfWeekTime;
    }

    // ----------|  Parsing  |------------------------------------------

    // This text output invented here in this method is styled to follow the designs of ISO 8601,
    // but is most certainly *not* defined in the standard.
    static public DayOfWeekTime parse ( final String input )
    {
        Objects.requireNonNull( input , "Received a null argument. Message # 7c519b65-a1ec-486b-a9e9-ff31ee1b8057." );
        if ( input.isEmpty() || input.isBlank() )
        {
            throw new IllegalArgumentException( "Received empty/blank string as argument. Message # 59993300-bdf9-4e69-82e0-823456715c60." );
        }

        DayOfWeek dayOfWeek = null;
        LocalTime localTime = null;

        // My regex powers are weak.
        // My half-baked scheme returns tokens = [, -1, 13:00] for an input of "W-1T13:00".
        String delimiters = "[WT]+";
        String[] tokens = input.toUpperCase( Locale.US ).split( delimiters );  // ISO 8601 requires the output of uppercase letters while mandating that we accept lowercase.
        System.out.println( "DEBUG tokens = " + Arrays.toString( tokens ) );
        if ( tokens.length != 3 )
        {
            throw new IllegalArgumentException( "Received invalid string as argument. Message # e521a4e3-1ee5-46e9-b351-a5edb7206b82." );
        }

        int dowNumber = Math.abs( Integer.parseInt( tokens[ 1 ] ) );
        dayOfWeek = DayOfWeek.of( dowNumber );

        String localTimeInput = Objects.requireNonNull( tokens[ 2 ] , "The time-of-day component of the input is null. Message # 1faed491-abaa-42bd-b767-d876f8ba07d9." );
        if ( localTimeInput.isEmpty() || localTimeInput.isBlank() )
        {
            throw new IllegalArgumentException( "The time-of-day component of the input is empty/blank. Message # 98208025-d7c2-4b59-bc5f-fed7bec09741." );
        }
        try
        {
            localTime = LocalTime.parse( localTimeInput );
        }
        catch ( DateTimeParseException e )
        {
            throw new IllegalArgumentException( "The time-of-day component of the input is invalid. Message # 8de7e8d8-f4a3-478d-96d8-911454aced14." );
        }

        DayOfWeekTime dayOfWeekTime = DayOfWeekTime.of( dayOfWeek , localTime );
        return dayOfWeekTime;
    }


    // ----------|  Moment  |------------------------------------------

    public ZonedDateTime atDateInZone ( LocalDate localDate , ZoneId zoneId )
    {
        Objects.requireNonNull( zoneId , "Received null argument. Message # b8f70601-2b1d-4321-a57f-96384759a960." );
        LocalDate ld = localDate.with( TemporalAdjusters.nextOrSame( this.dayOfWeek ) ); // Move to the next date with a matching day-of-week if not a match.
        LocalDateTime ldt = LocalDateTime.of( ld , this.localTime );
        ZonedDateTime zdt = ldt.atZone( zoneId );
        return zdt;
    }


    // ----------|  Accessors  |------------------------------------------

    public DayOfWeek getDayOfWeek ( ) { return this.dayOfWeek; }

    public LocalTime getLocalTime ( ) { return this.localTime; }


    // ----------|  Object  |------------------------------------------

    @Override
    public boolean equals ( Object o )
    {
        if ( this == o ) return true;
        if ( o == null || getClass() != o.getClass() ) return false;
        DayOfWeekTime that = ( DayOfWeekTime ) o;
        return dayOfWeek == that.dayOfWeek &&
                localTime.equals( that.localTime );
    }

    @Override
    public int hashCode ( )
    {
        return Objects.hash( dayOfWeek , localTime );
    }

    // This text output invented here in this method  is styled to follow the designs of ISO 8601,
    // but is most certainly *not* defined in the standard.
    @Override
    public String toString ( )
    {
        String output = "W" + "-" + this.dayOfWeek.getValue() + "T" + this.localTime.toString();
        return output;
    }
}

Example usage for Sunday.

DayOfWeekTime dayOfWeekTime = DayOfWeekTime.parse( "W-7T13:00" );

DayOfWeekTime nineDaysPrior = dayOfWeekTime.minus( Period.ofDays( 9 ) );
DayOfWeekTime oneDayPrior = dayOfWeekTime.minus( Period.ofDays( 1 ) );
DayOfWeekTime oneDayLater = dayOfWeekTime.plus( Period.ofDays( 1 ) );
DayOfWeekTime twoDaysLater = dayOfWeekTime.plus( Period.ofDays( 2 ) );
DayOfWeekTime weekLater = dayOfWeekTime.plus( Period.ofWeeks( 1 ) );
DayOfWeekTime nineDaysLater = dayOfWeekTime.plus( Period.ofDays( 9 ) );

DayOfWeekTime twoHoursLater = dayOfWeekTime.plus( Duration.ofHours( 2 ) );
DayOfWeekTime nineHoursLater = dayOfWeekTime.plus( Duration.ofHours( 9 ) );
DayOfWeekTime twentyFourHoursLater = dayOfWeekTime.plus( Duration.ofHours( 24 ) );

ZoneId z = ZoneId.of( "America/Montreal" );
ZonedDateTime zdt = dayOfWeekTime.atDateInZone( LocalDate.now( z ) , z );

DayOfWeekTime now = DayOfWeekTime.now( z );

Dump to console.

System.out.println( "After parsing: dayOfWeekTime = " + dayOfWeekTime );

System.out.println( "nineDaysPrior = " + nineDaysPrior );
System.out.println( "oneDayPrior = " + oneDayPrior );
System.out.println( "oneDayLater = " + oneDayLater );
System.out.println( "twoDaysLater = " + twoDaysLater );
System.out.println( "weekLater = " + weekLater );
System.out.println( "nineDaysLater = " + nineDaysLater );

System.out.println( "twoHoursLater = " + twoHoursLater );
System.out.println( "nineHoursLater = " + nineHoursLater );
System.out.println( "twentyFourHoursLater = " + twentyFourHoursLater );

System.out.println( "zdt = " + zdt );
System.out.println( "now = " + now );
DEBUG tokens = [, -7, 13:00]
After parsing: dayOfWeekTime = W-7T13:00
nineDaysPrior = W-5T13:00
oneDayPrior = W-6T13:00
oneDayLater = W-1T13:00
twoDaysLater = W-2T13:00
weekLater = W-7T13:00
nineDaysLater = W-2T13:00
twoHoursLater = W-7T15:00
nineHoursLater = W-7T22:00
twentyFourHoursLater = W-1T13:00
zdt = 2020-05-03T13:00-04:00[America/Montreal]
now = W-1T16:10:33.248253

And for Monday.

DayOfWeekTime dayOfWeekTime = DayOfWeekTime.parse( "W-1T13:00" );
…
DEBUG tokens = [, -1, 13:00]
After parsing: dayOfWeekTime = W-1T13:00
nineDaysPrior = W-6T13:00
oneDayPrior = W-7T13:00
oneDayLater = W-2T13:00
twoDaysLater = W-3T13:00
weekLater = W-1T13:00
nineDaysLater = W-3T13:00
twoHoursLater = W-1T15:00
nineHoursLater = W-1T22:00
twentyFourHoursLater = W-2T13:00
zdt = 2020-04-27T13:00-04:00[America/Montreal]
now = W-1T16:16:11.543665

Seems like this functionality really could be useful to people, for handling workers’ shifts, or store opening/closing times. If this makes sense to others, perhaps someone would care to help on this pull-request feature on the ThreeTen-Extra project.

Martinmas answered 26/4, 2020 at 23:48 Comment(4)
haha, this is amazing! I'll leave @MCEmperor's post as the marked answer, as he did get there before you conceptually, but really: nice oneCondescend
@MitchellKehn I think you should accept the Answer by MC Emperor, because it is short and sweet, directly addressing your Question with a proper solution. But, FYI, who got there first is not a reason to accept an Answer on Stack Overflow. See: What answer should I accept: faster vs more documented?.Martinmas
Update: Fixed a serious flaw in the class where the plus/minus methods were moving from the baseline by time-of-day but not by day-of-week. I added this call in four methods: .with( TemporalAdjusters.nextOrSame( this.dayOfWeek ) ).Martinmas
It looks like there is already such a pull request.Hutchens

© 2022 - 2024 — McMap. All rights reserved.