12

We're creating a scheduling application and we need to represent someone's available schedule during the day, regardless of what time zone they are in. Taking a cue from Joda Time's Interval, which represents an interval in absolute time between two instances (start inclusive, end exclusive), we created a LocalInterval. The LocalInterval is made up of two LocalTimes (start inclusive, end exclusive), and we even made a handy class for persisting this in Hibernate.

For example, if someone is available from 1:00pm to 5:00pm, we would create:

new LocalInterval(new LocalTime(13, 0), new LocalTime(17, 0));

So far so good---until someone wants to be available from 11:00pm until midnight on some day. Since the end of an interval is exclusive, this should be easily represented as such:

new LocalInterval(new LocalTime(23, 0), new LocalTime(24, 0));

Ack! No go. This throws an exception, because LocalTime cannot hold any hour greater than 23.

This seems like a design flaw to me---Joda didn't consider that someone may want a LocalTime that represents a non-inclusive endpoint.

This is really frustrating, as it blows a hole in what was otherwise a very elegant model that we created.

What are my options---other than forking Joda and taking out the check for hour 24? (No, I don't like the option of using a dummy value---say 23:59:59---to represent 24:00.)

Update: To those who keep saying that there is no such thing as 24:00, here's a quote from ISO 8601-2004 4.2.3 Notes 2,3: "The end of one calendar day [24:00] coincides with [00:00] at the start of the next calendar day ..." and "Representations where [hh] has the value [24] are only preferred to represent the end of a time interval ...."

Garret Wilson
  • 18,219
  • 30
  • 144
  • 272
  • From the context of your question, I'm guessing you meant to say "The LocalInterval is made up of two LocalTimes (start inclusive, end exclusive), ... " – MusiGenesis Mar 21 '11 at 23:47
  • @MusiGenesis: Yes, that was a typo. I've updated the question to indicate end exclusive. Thanks. – Garret Wilson Mar 22 '11 at 01:19
  • Very interesting question. It has inspired me some time ago to introduce the feature of 24:00 into my own date/time-library Time4J. Its similar pendant to `LocalTime`, namely `PlainTime` supports the slightly wider range 00:00/24:00. And I found that this feature does not make any problems (the time order is still pretty clear) but can help to solve some other issues in a more elegant way (for example the IANA-TZDB uses the value 24:00, too). – Meno Hochschild Jun 15 '15 at 12:34

9 Answers9

5

Well after 23:59:59 comes 00:00:00 on the next day. So maybe use a LocalTime of 0, 0 on the next calendar day?

Although since your start and end times are inclusive, 23:59:59 is really what you want anyways. That includes the 59th second of the 59th minute of the 23rd hour, and ends the range exactly on 00:00:00.

There is no such thing as 24:00 (when using LocalTime).

aroth
  • 54,026
  • 20
  • 135
  • 176
  • Sorry, my original question had a typo. I meant "end exclusive", so what I want is 24:00. There is indeed such a thing as 24:00. See ISO 8601-2004 Section 4.2.3 "Midnight". Because my end is exclusive, it is exactly what I want. – Garret Wilson Mar 22 '11 at 01:19
  • 1
    @garrett-wilson - After reading through some of the Joda docs I have a couple of suggestions. First, have you tried using the `LocalTime.MIDNIGHT` constant? Second, what the spec says and what the implementation does are not necessarily the same. Can you try something like `System.out.println(new LocalTime(12,0).getChronology().hourOfDay().getMaximumValue())`? If the value is less than 24, then 24:00 does not exist in the implementation. Which might be something you can raise as a bug in Joda. – aroth Mar 22 '11 at 02:57
  • 3
    "Well after 23:59:59 comes 00:00:00 on the next day." Not necessarily. Sometimes after 23:59:59 comes 23:59:60 - http://hpiers.obspm.fr/iers/bul/bulc/bulletinc.dat – Jonas Jun 20 '12 at 12:06
  • "There is no such thing as 24:00." is inaccurate. LocalTime does not support days > 23:59 but that's not to say they don't exist. GTFS operates on > 24 hours days. https://developers.google.com/transit/gtfs/reference/#stop_timestxt – Linus Norton Dec 21 '17 at 07:46
  • @LinusNorton Here it is not `LocalTime` which is what clock face shows you, but `Duration` which is how long some process may be. – kan Jun 15 '22 at 06:49
4

The solution we finally went with was to use 00:00 as a stand-in for 24:00, with logic throughout the class and the rest of the application to interpret this local value. This is a true kludge, but it's the least intrusive and most elegant thing I could come up with.

First, the LocalTimeInterval class keeps an internal flag of whether the interval endpoint is end-of-day midnight (24:00). This flag will only be true if the end time is 00:00 (equal to LocalTime.MIDNIGHT).

/**
 * @return Whether the end of the day is {@link LocalTime#MIDNIGHT} and this should be considered midnight of the
 *         following day.
 */
public boolean isEndOfDay()
{
    return isEndOfDay;
}

By default the constructor considers 00:00 to be beginning-of-day, but there is an alternate constructor for manually creating an interval that goes all day:

public LocalTimeInterval(final LocalTime start, final LocalTime end, final boolean considerMidnightEndOfDay)
{
    ...
    this.isEndOfDay = considerMidnightEndOfDay && LocalTime.MIDNIGHT.equals(end);
}

There is a reason why this constructor doesn't just have a start time and an "is end-of-day" flag: when used with a UI with a drop-down list of times, we don't know if the user will choose 00:00 (which is rendered as 24:00), but we know that as the drop-down list is for the end of the range, in our use case it means 24:00. (Although LocalTimeInterval allows empty intervals, we don't allow them in our application.)

Overlap checking requires special logic to take care of 24:00:

public boolean overlaps(final LocalTimeInterval localInterval)
{
    if (localInterval.isEndOfDay())
    {
        if (isEndOfDay())
        {
            return true;
        }
        return getEnd().isAfter(localInterval.getStart());
    }
    if (isEndOfDay())
    {
        return localInterval.getEnd().isAfter(getStart());
    }
    return localInterval.getEnd().isAfter(getStart()) && localInterval.getStart().isBefore(getEnd());
}

Similarly, converting to an absolute Interval requires adding another day to the result if isEndOfDay() returns true. It is important that application code never constructs an Interval manually from a LocalTimeInterval's start and end values, as the end time may indicate end-of-day:

public Interval toInterval(final ReadableInstant baseInstant)
{
    final DateTime start = getStart().toDateTime(baseInstant);
    DateTime end = getEnd().toDateTime(baseInstant);
    if (isEndOfDay())
    {
        end = end.plusDays(1);
    }
    return new Interval(start, end);
}

When persisting LocalTimeInterval in the database, we were able to make the kludge totally transparent, as Hibernate and SQL have no 24:00 restriction (and indeed have no concept of LocalTime anyway). If isEndOfDay() returns true, our PersistentLocalTimeIntervalAsTime implementation stores and retrieves a true time value of 24:00:

    ...
    final Time startTime = (Time) Hibernate.TIME.nullSafeGet(resultSet, names[0]);
    final Time endTime = (Time) Hibernate.TIME.nullSafeGet(resultSet, names[1]);
    ...
    final LocalTime start = new LocalTime(startTime, DateTimeZone.UTC);
    if (endTime.equals(TIME_2400))
    {
        return new LocalTimeInterval(start, LocalTime.MIDNIGHT, true);
    }
    return new LocalTimeInterval(start, new LocalTime(endTime, DateTimeZone.UTC));

and

    final Time startTime = asTime(localTimeInterval.getStart());
    final Time endTime = localTimeInterval.isEndOfDay() ? TIME_2400 : asTime(localTimeInterval.getEnd());
    Hibernate.TIME.nullSafeSet(statement, startTime, index);
    Hibernate.TIME.nullSafeSet(statement, endTime, index + 1);

It's sad that we had to write a workaround in the first place; this is the best I could do.

Garret Wilson
  • 18,219
  • 30
  • 144
  • 272
  • For a scheduling application, I think that *is* the most elegant solution. How many times I had trouble by someone in outlook trying to represent their 'whole day event' by matching exact time: shift that into our timezone differences, and our whole calendar is a mess. This is exactly the reason why in outlook you have a separate toggle to represent a whole day. – YoYo Jan 12 '16 at 03:08
3

It's not a design flaw. LocalDate doesn't handle (24,0) because there's no such thing as 24:00.

Also, what happens when you want to represent an interval between, say 9pm and 3am?

What's wrong with this:

new LocalInterval(new LocalTime(23, 0), new LocalTime(0, 0));

You just have to handle the possibility that the end time might be "before" the start time, and add a day when necessary, and just hope that noone wants to represent an interval longer than 24 hours.

Alternatively, represent the interval as a combination of a LocalDate and a Duration or Period. That removes the "longer than 24 hours" problem.

skaffman
  • 398,947
  • 96
  • 818
  • 769
  • 3
    "[T]here's no such thing as 24:00". Indeed there is. See ISO 8601-2004 Section 4.2.3 "Midnight". Because my end is exclusive, it is exactly what I want. – Garret Wilson Mar 22 '11 at 01:25
  • What is wrong with the example you gave is that it represents a negative interval, from 11:00pm to 00:00am, which occurred 11 hours earlier. Consider the interval from 00:00 to 00:00---is this an interval with no length, or an interval for the entire day (ignoring DST changes)? It should be the former---an interval of the entire day, end exclusive, would be 00:00 to 24:00. This would all work brilliantly and be ISO 8601 compliant if it weren't for a single line of code somewhere throwing an exception because it doesn't like the value 24. – Garret Wilson Mar 22 '11 at 01:26
  • @GarretWilson This is good approach. Just consider that interval is semi-open inclusive-exclusive. If begin – kan Dec 04 '21 at 19:56
2

Your problem can be framed as defining an interval on a domain that wraps around. Your min is 00:00, and your max is 24:00 (not inclusive).

Suppose your interval is defined as (lower, upper). If you require that lower < upper, you can represent (21:00, 24:00), but you are still unable to represent (21:00, 02:00), an interval that wraps across the min/max boundary.

I don't know whether your scheduling application would involve wrap-around intervals, but if you are going to go to (21:00, 24:00) without involving days, I don't see what will stop you from requiring (21:00, 02:00) without involving days (thus leading to a wrap-around dimension).

If your design is amenable to a wrap-around implementation, the interval operators are quite trivial.

For example (in pseudo-code):

is x in (lower, upper)? :=
if (lower <= upper) return (lower <= x && x <= upper)
else return (lower <= x || x <= upper)

In this case, I have found that writing a wrapper around Joda-Time implementing the operators is simple enough, and reduces impedance between thought/math and API. Even if it is just for the inclusion of 24:00 as 00:00.

I do agree that the exclusion of 24:00 annoyed me at the start, and it'll be nice if someone offered a solution. Luckily for me, given that my use of time intervals is dominated by wrap-around semantics, I always end up with a wrapper, which incidentally solves the 24:00 exclusion.

Dingfeng Quek
  • 898
  • 5
  • 14
1

this is our implementation of TimeInterval, using null as end Date for end-of-day. It supports the overlaps() and contains() methods and is also based on joda-time. It supports intervals spanning multiple days.

/**
 * Description: Immutable time interval<br>
 * The start instant is inclusive but the end instant is exclusive.
 * The end is always greater than or equal to the start.
 * The interval is also restricted to just one chronology and time zone.
 * Start can be null (infinite).
 * End can be null and will stay null to let the interval last until end-of-day.
 * It supports intervals spanning multiple days.
 */
public class TimeInterval {

    public static final ReadableInstant INSTANT = null; // null means today
//    public static final ReadableInstant INSTANT = new Instant(0); // this means 1st jan 1970

    private final DateTime start;
    private final DateTime end;

    public TimeInterval() {
        this((LocalTime) null, null);
    }

    /**
     * @param from - null or a time  (null = left unbounded == LocalTime.MIDNIGHT)
     * @param to   - null or a time  (null = right unbounded)
     * @throws IllegalArgumentException if invalid (to is before from)
     */
    public TimeInterval(LocalTime from, LocalTime to) throws IllegalArgumentException {
        this(from == null ? null : from.toDateTime(INSTANT),
                to == null ? null : to.toDateTime(INSTANT));
    }

    /**
     * create interval spanning multiple days possibly.
     *
     * @param start - start distinct time
     * @param end   - end distinct time
     * @throws IllegalArgumentException - if start > end. start must be <= end
     */
    public TimeInterval(DateTime start, DateTime end) throws IllegalArgumentException {
        this.start = start;
        this.end = end;
        if (start != null && end != null && start.isAfter(end))
            throw new IllegalArgumentException("start must be less or equal to end");
    }

    public DateTime getStart() {
        return start;
    }

    public DateTime getEnd() {
        return end;
    }

    public boolean isEndUndefined() {
        return end == null;
    }

    public boolean isStartUndefined() {
        return start == null;
    }

    public boolean isUndefined() {
        return isEndUndefined() && isStartUndefined();
    }

    public boolean overlaps(TimeInterval other) {
        return (start == null || (other.end == null || start.isBefore(other.end))) &&
                (end == null || (other.start == null || other.start.isBefore(end)));
    }

    public boolean contains(TimeInterval other) {
        return ((start != null && other.start != null && !start.isAfter(other.start)) || (start == null)) &&
                ((end != null && other.end != null && !other.end.isAfter(end)) || (end == null));
    }

    public boolean contains(LocalTime other) {
        return contains(other == null ? null : other.toDateTime(INSTANT));
    }

    public boolean containsEnd(DateTime other) {
        if (other == null) {
            return end == null;
        } else {
            return (start == null || !other.isBefore(start)) &&
                    (end == null || !other.isAfter(end));
        }
    }

    public boolean contains(DateTime other) {
        if (other == null) {
            return start == null;
        } else {
            return (start == null || !other.isBefore(start)) &&
                    (end == null || other.isBefore(end));
        }
    }

    @Override
    public String toString() {
        final StringBuilder sb = new StringBuilder();
        sb.append("TimeInterval");
        sb.append("{start=").append(start);
        sb.append(", end=").append(end);
        sb.append('}');
        return sb.toString();
    }
}
Roman
  • 3,094
  • 1
  • 14
  • 11
1

For the sake of completeness this test fails:

@Test()
public void testJoda() throws DGConstraintViolatedException {
    DateTimeFormatter simpleTimeFormatter = DateTimeFormat.forPattern("HHmm");
    LocalTime t1 = LocalTime.parse("0000", simpleTimeFormatter);
    LocalTime t2 = LocalTime.MIDNIGHT;
    Assert.assertTrue(t1.isBefore(t2));
}  

This means the MIDNIGHT constant is not very usefull for the problem, as someone suggested.

Jaime Casero
  • 371
  • 2
  • 6
1

This question is old, but many of these answers focus on Joda Time, and only partly address the true underlying problem:

The model in the OP's code doesn't match the reality it's modeling.

Unfortunately, since you do appear to care about the boundary condition between days, your "otherwise elegant model" isn't a good match for the problem you are modeling. You've used a pair of time values to represent intervals. Attempting to simplify the model down to a pair of times is simplifying below the complexity of the real world problem. Day boundaries actually do exist in reality and a pair of times looses that type of information. As always, over simplification results in subsequent complexity to restore or compensate for the missing information. Real complexity can only be pushed around from one part of the code to another.

The complexity of reality can only be eliminated with the magic of "unsupported use cases".

Your model would only make sense in a problem space where one didn't care how many days might exist between the start and end times. That problem space doesn't match most real world problems. Therefore, it's not surprising that Joda Time doesn't support it well. The use of 25 values for the hours place (0-24) is a code smell and usually points to a weakness in the design. There are only 24 hours in the day so 25 values should not be needed!

Note that since you aren't capturing the date on either end of LocalInterval, your class also does not capture sufficient information to account for daylight savings time. [00:30:00 TO 04:00:00) is usually 3.5 hours long but could also be 2.5, or 4.5 hours long.

You should either use a start date/time and duration, or a start date/time and an end date/time (inclusive start, exclusive end is a good default choice). Using a duration becomes tricky if you intend to display the end time because of things like daylight savings time, leap years and leap seconds. On the other hand using an end date becomes just as tricky if you expect to display the duration. Storing both of course is dangerous because it violates the DRY principle. If I were writing such a class I would store an end date/time and encapsulate the logic for obtaining the duration via a method on the object. That way clients of the class class do not all come up with their own code to calculate the duration.

I'd code up a example, but there's an even better option. Use the standard Interval Class from Joda time, which already accepts a start instant and either duration or end instant. It will also and happily calculate the duration or the end time for you. Sadly JSR-310 doesn't have an interval or similar class. (though one can use ThreeTenExtra to make up for that)

The relatively bright folks at Joda Time and Sun/Oracle (JSR-310) both thought very carefully about these problems. You might be smarter than them. It's possible. However, even if you are a brighter bulb, your 1 hour is probably not going to accomplish what they spent years on. Unless you are somewhere out in an esoteric edge case, it's usually waste of time and money to spend effort second guessing them. (of course at the time of the OP JSR-310 wasn't complete...)

Hopefully the above will help folks who find this question while designing or fixing similar issues.

Community
  • 1
  • 1
Gus
  • 6,719
  • 6
  • 37
  • 58
1

The time 24:00 is a difficult one. While we humans can understand what is meant, coding up an API to represent that without negatively impacting everything else appears to me to be nigh on impossible.

The value 24 being invalid is deeply encoded in Joda-Time - trying to remove it would have negative implications in a lot of places. I wouldn't recommend trying to do that.

For your problem, the local interval should consist of either (LocalTime, LocalTime, Days) or (LocalTime, Period). The latter is slightly more flexible. This is needed to correctly support an interval from 23:00 to 03:00.

JodaStephen
  • 60,927
  • 15
  • 95
  • 117
  • So consider how your proposal of (LocalTime, Period) would have worked on 13 March 2011 in the USA. If I mark my availability on Sunday from 00:00-12:00, using your approach would use (00:00, 12hours). Because we lost an hour on 13 March, your representation would produce 00:00-13:00 for that day, as adding 12 hours to the DateTime of midnight would give 1:00pm. If I had instead kept the end time as a LocalTime, resolving 12:00 to the DateTime of midnight would give me the correct time---the interval on 2011-03-13 would be shorter because of DST. – Garret Wilson Mar 22 '11 at 14:10
1

I find JodaStephen's proposal of (LocalTime, LocalTime, Days) acceptable.

Considering on 13 March 2011 and your availability on Sunday from 00:00-12:00 you would have (00:00, 12:00, 0) which were in fact 11 hours long because of DST.

An availability from say 15:00-24:00 you could then code as (15:00, 00:00, 1) which would expanded to 2011-03-13T15:00 - 2011-03-14T00:00 whereat the end would be desired 2011-03-13T24:00. That means you would use a LocalTime of 00:00 on the next calendar day like already aroth proposed.

Of course it would be nice to use a 24:00 LocalTime directly and ISO 8601 conform but this seems not possible without changing a lot inside JodaTime so this approach seems the lesser evil.

And last but not least you could even extend the barrier of a single day with something like (16:00, 05:00, 1)...

Community
  • 1
  • 1
binuWADa
  • 616
  • 2
  • 7
  • 15