12

I'm using openjdk version 1.8.0_112-release for development but will need to support previous JDK versions too (pre-Java-8) - so can't use java.time.

I am writing a utitily class to calculate the date to see if a saved date is before the current date which means its expired.

However, I am not sure I have done this the correct way. I am using LocalDate class to calculate the days. The expiration is counted starting from the date and time the user clicked save. That date will be saved and a check will be done against this saved date and time and the current date and time i.e. when the user logs in.

Is this the best way to do it? I would like to keep to the LocalDate class.

import org.threeten.bp.LocalDate;

public final class Utilities {
    private Utilities() {}

    public static boolean hasDateExpired(int days, LocalDate savedDate, LocalDate currentDate) {
        boolean hasExpired = false;

        if(savedDate != null && currentDate != null) {
            /* has expired if the saved date plus the specified days is still before the current date (today) */
            if(savedDate.plusDays(days).isBefore(currentDate)) {
                hasExpired = true;
            }       
        }

        return hasExpired;
    }
}

I'm using the class like this:

private void showDialogToIndicateLicenseHasExpired() {
    final LocalDate currentDate = LocalDate.now();
    final int DAYS_TO_EXPIRE = 3;
    final LocalDate savedDate = user.getSavedDate();

    if(hasDateExpired(DAYS_TO_EXPIRE, savedDate, currentDate)) {
        /* License has expired, warn the user */    
    }
}

I am looking a solution that will take in account time zones. If a license was set to expire in 3 days, and the user was to travel to a different time zone. i.e. they could be ahead or behind based on hours. The license should still expire.

ant2009
  • 30,351
  • 141
  • 365
  • 559
  • 3
    If you're using java 8, you don't need to use `org.threeten.bp`, you can use `java.time.LocalDate` class –  May 26 '17 at 18:10
  • As Hugo says, org.threeten.bp is from the [ThreeTen-Backport](http://www.threeten.org/threetenbp/) project. That library back-ports much of the java.time functionality to Java 6 & Java 7. No need for the back-port in Java 8 and Java 9 as java.time is built-in. – Basil Bourque May 26 '17 at 20:02
  • So please, when exactly should the expiration happen? Is that simply 72 hours after save time (3 * 24 = 72)? Or not until midnight? Say user clicked save on 26 May 11:30 am, should expiration then be at midnight between 29 May and 30 May in the time zone where data was saved? Or, third option, on the same clock time 3 days later? This last may occasionally give you 71 or 73 hours on the transistion to or from summer time (daylight saving time). – Ole V.V. May 29 '17 at 06:56
  • 1
    @OleV.V. Reading your comment it should be 72 hours from the time that the user saved the date. So midnight should be ignored as we are counting down the hours. – ant2009 May 29 '17 at 08:07
  • don't write your own timezone code, use a library https://www.youtube.com/watch?v=-5wpm-gesOY – Testo Testini Jun 04 '17 at 23:30

7 Answers7

10

Your code is basically fine. I would do it basically the same way, just with a detail or two being different.

As Hugo has already noted, I would use java.time.LocalDate and drop the use of ThreeTen Backport (unless it is a specific requirement that your code can run on Java 6 or 7 too).

Time Zone

You should decide in which time zone you count your days. Also I would prefer if you make the time zone explicit in yout code. If your system will be used in your own time zone only, the choice is easy, just make it explicit. For example:

    final LocalDate currentDate = LocalDate.now(ZoneId.of("Asia/Hong_Kong"));

Please fill in the relevant zone ID. This will also make sure the program works correctly even if one day it happens to run on a computer with an incorrect time zone setting. If your system is global, you may want to use UTC, for example:

    final LocalDate currentDate = LocalDate.now(ZoneOffset.UTC);

You will want to do similarly when saving the date when the user clicked Save so your data are consistent.

72 hours

Edit: I understand from your comment that you want to measure 3 days, that is, 72 hours, from the save time to determine whether the license has expired. For this a LocalDate does not give you enough information. It is only a date without a clock time, like May 26 2017 AD. There are some other options:

  • Instant is a point in time (with nanosecond precision, even). This is the simple solution to make sure the expiration happens after 72 hours no matter if the user moves to another time zone.
  • ZonedDateTime represents both a date and a time and a time zone, like 29 May 2017 AD 19:21:33.783 at offset GMT+08:00[Asia/Hong_Kong]. If you want to remind the user when the saved time was, a ZonedDateTime will you allow you to present that information with the time zone in which the save date was calculated.
  • Finally OffsetDateTime would work too, but it doesn’t seem to give you much of the advantages of the two others, so I will not eloborate on this option.

Since an instant is the same in all time zones, you don’t specify a time zone when getting the current instant:

    final Instant currentDate = Instant.now();

Adding 3 days to an Instant is a little different LocalDate, but the rest of the logic is the same:

public static boolean hasDateExpired(int days, Instant savedDate, Instant currentDate) {
    boolean hasExpired = false;

    if(savedDate != null && currentDate != null) {
        /* has expired if the saved date plus the specified days is still before the current date (today) */
        if (savedDate.plus(days, ChronoUnit.DAYS).isBefore(currentDate)) {
            hasExpired = true;
        }       
    }

    return hasExpired;
}

The use of ZonedDateTime, on the other hand, goes exactly like LocalDate in the code:

    final ZonedDateTime currentDate = ZonedDateTime.now(ZoneId.of("Asia/Hong_Kong"));

If you want the current time zone setting from the JVM where the program runs:

    final ZonedDateTime currentDate = ZonedDateTime.now(ZoneId.systemDefault());

Now if you declare public static boolean hasDateExpired(int days, ZonedDateTime savedDate, ZonedDateTime currentDate), you may do as before:

        /* has expired if the saved date plus the specified days is still before the current date (today) */
        if (savedDate.plusDays(days).isBefore(currentDate)) {
            hasExpired = true;
        }       

This will perform the correct comparison even if the two ZonedDateTime objects are in two different time zones. So no matter if the user travels to a different time zone, s/he will not get fewer nor more hours before the license expires.

Ole V.V.
  • 65,573
  • 11
  • 96
  • 117
  • 1
    Yes, this is the one important Answer posted so far. **Time zone is crucial** to determining the current date. So the expected/desired zone should be specified explicitly. Relying implicitly on the JVM`s current default time zone leads to unreliable behavior as that default may be unexpected and may even be changed *during* runtime(!) by any code in any app within that JVM. – Basil Bourque May 26 '17 at 20:11
  • Would this work if the user was to travel to different time zone not just located in Asia? – ant2009 May 29 '17 at 01:30
  • 1
    @ant2009 point is that most likely you want `currentDate` and `savedDate` calculated in the same time zone, or otherwise use a date that includes time zone. That should even work in the scenario that you move and test the date on a different client. – YoYo May 29 '17 at 01:53
8

You can use ChronoUnit.DAYS (in org.threeten.bp.temporal package, or in java.time.temporal if you use java 8 native classes) to calculate the number of days between the 2 LocalDate objects:

if (savedDate != null && currentDate != null) {
    if (ChronoUnit.DAYS.between(savedDate, currentDate) > days) {
        hasExpired = true;
    }
}

Edit (after bounty explanation)

For this test, I'm using threetenbp version 1.3.4

As you want a solution that works even if the user is in a different timezone, you shouldn't use LocalDate, because this class doesn't handle timezone issues.

I think the best solution is to use the Instant class. It represents a single point in time, no matter in what timezone you are (at this moment, everybody in the world are in the same instant, although the local date and time might be different depending on where you are).

Actually Instant is always in UTC Time - a standard indepedent of timezone, so very suitable to your case (as you want a calculation independent of what timezone the user is in).

So both your savedDate and currentDate must be Instant's, and you should calculate the difference between them.

Now, a subtle detail. You want the expiration to happen after 3 days. For the code I did, I'm making the following assumptions:

  • 3 days = 72 hours
  • 1 fraction of a second after 72 hours, it's expired

The second assumption is important for the way I implemented the solution. I'm considering the following cases:

  1. currentDate is less than 72 hours after savedDate - not expired
  2. currentDate is exactly 72 hours after savedDate - not expired (or expired? see comments below)
  3. currentDate is more than 72 hours after savedDate (even by a fraction of a second) - expired

The Instant class has nanosecond precision, so in case 3 I'm considering that it's expired even if it's 1 nanosecond after 72 hours:

import org.threeten.bp.Instant;
import org.threeten.bp.temporal.ChronoUnit;

public static boolean hasDateExpired(int days, Instant savedDate, Instant currentDate) {
    boolean hasExpired = false;

    if (savedDate != null && currentDate != null) {
        // nanoseconds between savedDate and currentDate > number of nanoseconds in the specified number of days
        if (ChronoUnit.NANOS.between(savedDate, currentDate) > days * ChronoUnit.DAYS.getDuration().toNanos()) {
            hasExpired = true;
        }
    }

    return hasExpired;
}

Note that I used ChronoUnit.DAYS.getDuration().toNanos() to get the number of nanoseconds in a day. It's better to rely on the API instead of having hardcoded big error-prone numbers.

I've made some tests, using dates in the same timezone and in different ones. I used ZonedDateTime.toInstant() method to convert the dates to Instant:

import org.threeten.bp.ZoneId;
import org.threeten.bp.ZonedDateTime;

// testing in the same timezone
ZoneId sp = ZoneId.of("America/Sao_Paulo");
// savedDate: 22/05/2017 10:00 in Sao Paulo timezone
Instant savedDate = ZonedDateTime.of(2017, 5, 22, 10, 0, 0, 0, sp).toInstant();
// 1 nanosecond before expires (returns false - not expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 9, 59, 59, 999999999, sp).toInstant()));
// exactly 3 days (72 hours) after saved date (returns false - not expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 10, 0, 0, 0, sp).toInstant()));
// 1 nanosecond after 3 days (72 hours) (returns true - expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 10, 0, 0, 1, sp).toInstant()));

// testing in different timezones (savedDate in Sao Paulo, currentDate in London)
ZoneId london = ZoneId.of("Europe/London");
// In 22/05/2017, London will be in summer time, so 10h in Sao Paulo = 14h in London
// 1 nanosecond before expires (returns false - not expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 13, 59, 59, 999999999, london).toInstant()));
// exactly 3 days (72 hours) after saved date (returns false - not expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 14, 0, 0, 0, london).toInstant()));
// 1 nanosecond after 3 days (72 hours) (returns true - expired)
System.out.println(hasDateExpired(3, savedDate, ZonedDateTime.of(2017, 5, 25, 14, 0, 0, 1, london).toInstant()));

PS: for case 2 (currentDate is exactly 72 hours after savedDate - not expired) - if you want this to be expired, just change the if above to use >= instead of >:

if (ChronoUnit.NANOS.between(savedDate, currentDate) >= days * ChronoUnit.DAYS.getDuration().toNanos()) {
    ... // it returns "true" for case 2
}

If you don't want nanosecond precision and just want to compare the days between the dates, you can do as in @Ole V.V's answer. I believe our answers are very similar (and I suspect that the codes are equivalent, although I'm not sure), but I haven't tested enough cases to check if they differ in any particular situation.

  • I will have to keep with the org.threeten.bp.LocalDate as we have to support previous versions. – ant2009 May 29 '17 at 01:31
  • 1
    No problem, the code will basically be the same. Threeten is a backport and most functionality is implemented by it. –  May 29 '17 at 01:32
  • @ant2009 After seeing the bounty explanation (it must work in different timezones), I've updated the answer –  May 29 '17 at 13:02
5

The Answer by Hugo and the Answer by Ole V.V. Are both correct, and the one by Ole V.V. is most important, as time zone is crucial to determine the current date.

Period

Another useful class for this work is the Period class. This class represents a span of time unattached to the timeline as a number of years, months, and days.

Note that this class is not appropriate to representing the elapsed time needed for this Question because this representation is "chunked" as years, then months, and then any remaining days. So if LocalDate.between( start , stop ) were used for an amount of several weeks, the result might be something like "two months and three days". Notice that this class does not implement the Comparable interface for this reason, as one pair of months cannot be said to be bigger or smaller than another pair unless we know which specific months are involved.

We can use this class to represent the two-day grace-period mentioned in the Question. Doing so makes our code more self-documenting. Better to pass around an object of this type than passing a mere integer.

Period grace = Period.ofDays( 2 ) ;

LocalDate start = LocalDate.of( 2017 , Month.JANUARY , 23 ).plusDays( grace ) ;
LocalDate stop = LocalDate.of( 2017 , Month.MARCH , 7 ) ;

We use ChronoUnit to calculate elapsed days.

int days = ChronoUnit.DAYS.between( start , stop ) ;

Duration

By the way, the Duration class is similar to Period in that it represents a span of time not attached to the timeline. But Duration represents a total of whole seconds plus a fractional second resolved in nanoseconds. From this you can calculate a number of generic 24-hour days (not date-based days), hours, minutes, seconds, and fractional second. Keep in mind that days are not always 24-hours long; here in the United States they currently may be 23, 24, or 25 hours long because of Daylight Saving Time.

This Question is about date-based days, not lumps of 24-hours. So the Duration class is not appropriate here.


About java.time

The java.time framework is built into Java 8 and later. These classes supplant the troublesome old legacy date-time classes such as java.util.Date, Calendar, & SimpleDateFormat.

The Joda-Time project, now in maintenance mode, advises migration to the java.time classes.

To learn more, see the Oracle Tutorial. And search Stack Overflow for many examples and explanations. Specification is JSR 310.

Where to obtain the java.time classes?

The ThreeTen-Extra project extends java.time with additional classes. This project is a proving ground for possible future additions to java.time. You may find some useful classes here such as Interval, YearWeek, YearQuarter, and more.

Basil Bourque
  • 218,480
  • 72
  • 657
  • 915
2

I think much better to use this:

Duration.between(currentDate.atStartOfDay(), savedDate.atStartOfDay()).toDays() > days;

Duration class placed in java.time package.

eg04lt3r
  • 2,269
  • 12
  • 19
  • 1
    (A) You want `Period` here, not `Duration`, to count date-based days. (B) Why "much better"? Both approaches seem equally suited, to my mind. – Basil Bourque May 26 '17 at 20:06
  • 1
    @BasilBourque, (A) I want `Duration` as I mentioned above. (B) Much better because I can solve this task and this solution works well if I need get duration in another time unit. This is my subjective opinion based on my personal experience. – eg04lt3r May 26 '17 at 20:16
  • 1
    @BasilBourque, do you know how Period is calculating days? How it can be used in case described? – eg04lt3r May 26 '17 at 20:19
  • 1
    `Period` is explicitly built for `LocalDate`. Your "subjective opinion" is irrelevant if it fails to address the specific needs stated in the Question. – Basil Bourque May 26 '17 at 20:22
  • 1
    @BasilBourque, what my solution fails in the Question? – eg04lt3r May 26 '17 at 20:28
1

As this question is not getting "enough responses", I have added another answer:

I have used "SimpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));" to set the timezone to UTC. So there is no longer a timezone (all Date / time will be set to UTC).

savedDate is set to UTC.

dateTimeNow is also set to UTC, with the number of expired "days" (negative number) added to dateTimeNow.

A new Date expiresDate uses the long milliseconds from dateTimeNow

Check if savedDate.before(expiresDate)


package com.chocksaway;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;

public class ExpiredDate {
    private static final long DAY_IN_MS = 1000 * 60 * 60 * 24;

    private static boolean hasDateExpired(int days, java.util.Date savedDate) throws ParseException {
        SimpleDateFormat dateFormatUtc = new SimpleDateFormat("yyyy-MMM-dd HH:mm:ss");
        dateFormatUtc.setTimeZone(TimeZone.getTimeZone("UTC"));

        // Local Date / time zone
        SimpleDateFormat dateFormatLocal = new SimpleDateFormat("yyyy-MMM-dd HH:mm:ss");

        // Date / time in UTC
        savedDate = dateFormatLocal.parse( dateFormatUtc.format(savedDate));

        Date dateTimeNow = dateFormatLocal.parse( dateFormatUtc.format(new Date()));

        long expires = dateTimeNow.getTime() + (DAY_IN_MS * days);

        Date expiresDate = new Date(expires);

        System.out.println("savedDate \t\t" + savedDate + "\nexpiresDate \t" + expiresDate);

        return savedDate.before(expiresDate);
    }

    public static void main(String[] args) throws ParseException {
        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.DATE, 0);

        if (ExpiredDate.hasDateExpired(-2, cal.getTime())) {
            System.out.println("expired");
        } else {
            System.out.println("not expired");
        }

        System.out.print("\n");

        cal.add(Calendar.DATE, -3);

        if (ExpiredDate.hasDateExpired(-2, cal.getTime())) {
            System.out.println("expired");
        } else {
            System.out.println("not expired");
        }
    }
}

Running this code gives the following output:

savedDate       Mon Jun 05 15:03:24 BST 2017
expiresDate     Sat Jun 03 15:03:24 BST 2017
not expired

savedDate       Fri Jun 02 15:03:24 BST 2017
expiresDate     Sat Jun 03 15:03:24 BST 2017
expired

All dates / times are UTC. First is not expired. Second is expired (savedDate is before expiresDate).

chocksaway
  • 748
  • 1
  • 6
  • 19
0

KISS

public static boolean hasDateExpired(int days, java.util.Date savedDate) {
    long expires = savedDate().getTime() + (86_400_000L * days);
    return System.currentTimeMillis() > expires;
}

Works on old JRE's just fine. Date.getTime() gives milliseconds UTC, so timezone isn't even a factor. The magic 86'400'000 is the number of milliseconds in a day.

Instead of using java.util.Date you can simplify this further if you just use a long for savedTime.

Durandal
  • 19,415
  • 2
  • 32
  • 62
0

I have built a simple utility class ExpiredDate, with a TimeZone (such as CET), expiredDate, expireDays, and differenceInHoursMillis.

I use java.util.Date, and Date.before(expiredDate):

To see if Date() multiplied by expiryDays plus (timezone difference multiplied by expiryDays) is before expiredDate.

Any date older than the expiredDate is "expired".

A new Date is created by adding (i) + (ii):

(i). I use the number of milliseconds in a day to (DAY_IN_MS = 1000 * 60 * 60 * 24) which is multiplied with the (number of) expireDays.

+

(ii). To deal with a different TimeZone, I find the number of milliseconds between the Default timezone (for me BST), and the TimeZone (for example CET) passed into ExpiredDate. For CET, the difference is one hour, which is 3600000 milliseconds. This is multiplied by the (number of) expireDays.

The new Date is returned from parseDate().

If the new Date is before the expiredDate -> set expired to True. dateTimeWithExpire.before(expiredDate);

I have created 3 tests:

  1. Set the expiry date 7 days, and expireDays = 3

    Not expired (7 days is greater than 3 days)

  2. Set the expiry date / time, and expireDays to 2 days

    Not expired - because the CET timezone adds two hours (one hour per day) to the dateTimeWithExpire

  3. Set the expiry date 1 days, and expireDays = 2 (1 day is less than 2 days)

expired is true


package com.chocksaway;

import java.text.ParseException;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;

public class ExpiredDate {
    /**
     * milliseconds in a day
    */
    private static final long DAY_IN_MS = 1000 * 60 * 60 * 24;
    private String timeZone;
    private Date expiredDate;
    private int expireDays;
    private int differenceInHoursMillis;

    /**
     *
     * @param timeZone - valid timezone
     * @param expiredDate - the fixed date for expiry
     * @param expireDays - the number of days to expire
    */
    private ExpiredDate(String timeZone, Date expiredDate, int expireDays) {
        this.expiredDate = expiredDate;
        this.expireDays = expireDays;
        this.timeZone = timeZone;

        long currentTime = System.currentTimeMillis();
        int zoneOffset =   TimeZone.getTimeZone(timeZone).getOffset(currentTime);
        int defaultOffset = TimeZone.getDefault().getOffset(currentTime);

        /**
         * Example:
         *     TimeZone.getTimeZone(timeZone) is BST
         *     timeZone is CET
         *
         *     There is one hours difference, which is 3600000 milliseconds
         *
         */

        this.differenceInHoursMillis = (zoneOffset - defaultOffset);
    }


    /**
     *
     * Subtract a number of expire days from the date
     *
     * @param dateTimeNow - the date and time now
     * @return - the date and time minus the number of expired days
     *           + (difference in hours for timezone * expiryDays)
     *
     */
    private Date parseDate(Date dateTimeNow) {
        return new Date(dateTimeNow.getTime() - (expireDays * DAY_IN_MS) + (this.differenceInHoursMillis * expireDays));
    }


    private boolean hasDateExpired(Date currentDate) {
        Date dateTimeWithExpire = parseDate(currentDate);

        return dateTimeWithExpire.before(expiredDate);
    }


    public static void main(String[] args) throws ParseException {

        /* Set the expiry date 7 days, and expireDays = 3
        *
        * Not expired
        */

        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.DATE, -7);

        ExpiredDate expired = new ExpiredDate("CET", cal.getTime(), 3);

        Date dateTimeNow = new Date();

        if (expired.hasDateExpired(dateTimeNow)) {
            System.out.println("expired");
        } else {
            System.out.println("NOT expired");

        }

        /* Set the expiry date / time, and expireDays to 2 days
         *  Not expired - because the CET timezone adds two hours to the dateTimeWithExpire
        */


        cal = Calendar.getInstance();
        cal.add(Calendar.DATE, -2);

        expired = new ExpiredDate("CET", cal.getTime(), 2);

        dateTimeNow = new Date();

        if (expired.hasDateExpired(dateTimeNow)) {
            System.out.println("expired");
        } else {
            System.out.println("NOT expired");

        }

        /* Set the expiry date 1 days, and expireDays = 2
        *
        * expired
        */

        cal = Calendar.getInstance();
        cal.add(Calendar.DATE, -1);

        expired = new ExpiredDate("CET", cal.getTime(), 2);

        dateTimeNow = new Date();

        if (expired.hasDateExpired(dateTimeNow)) {
            System.out.println("expired");
        } else {
            System.out.println("NOT expired");

        }

     } 
 }
chocksaway
  • 748
  • 1
  • 6
  • 19