Search code examples
javadatetimedatetimeformatter

Java: How to get next time that matches pattern


Is there an easy/direct way to use a DateTimeFormatter pattern to get the next LocalDateTime time that matches that pattern?

I'd like to use this to easily get the next time that an event should happen (could be daily, weekly, monthly, etc.). For example, if an event happens at "Monday 12:00 AM", I would like to get a LocalDateTime for the next Monday at 12:00 AM.

    /**Get next LocalDateTime that matches this input
     * 
     * @param input a String for time matching the pattern: [dayOfWeek ][dayOfMonth ][month ][year ]<timeOfDay> <AM/PM>
     * @return LocalDateTime representing the next time that matches the input*/
    public LocalDateTime getNextTime(String input) {
        LocalDateTime currentTime = LocalDateTime.now();
        DateTimeFormatter format = DateTimeFormatter.ofPattern("[eeee ][d ][MMMM ][u ]h:m a");
        TemporalAccessor accessor = format.parse(input);
        // TODO somehow get the next time (that's after currentTime) that matches this pattern
        // LocalDateTime time = ???
        return time;
    }

I can't just do LocalDateTime.from(accessor) because there might not be a year, month, or day of month specified in the input.

To clarify, here are some examples of what I would like:

// if current date is Friday, January 1st, 2021 at 12:00 PM 

// this should return a LocalDateTime for Monday, January 4th, 2021 12:00 AM
getNextTime("Monday 12:00 AM");

// should return Saturday, January 2nd, 2021 12:00 AM
getNextTime("12:00 AM"); 

// should return Tuesday, January 5th, 2021 12:00 AM
getNextTime("5 January 12:00 AM");

// should return Friday, January 8th, 2021 12:00 PM (must be AFTER current time)
getNextTime("Friday 12:00 PM");

Solution

  • No, there is neither an easy nor a direct way to do what you are asking for. It involves quite a bit of coding. You basically have got 16 cases because each of year, month, day of month and day of week may or may not be present. And you more or less will have to handle each case separately.

    Also there may not be such a next time. If the year is 2019 there isn’t. If the string is Friday 12 January 2021 2:00 AM, there isn’t because 12 January is a Tuesday, not a Friday.

    private static DateTimeFormatter format = DateTimeFormatter
            .ofPattern("[eeee ][uuuu ][d ][MMMM ][uuuu ]h:m a", Locale.ENGLISH);
    
    // input = [dayOfWeek] [dayOfMonth] [month] [year] <timeOfDay> <AM/PM>
    public static LocalDateTime next(String text) {
        TemporalAccessor accessor;
        try {
            accessor = format.parse(text);
        } catch (DateTimeParseException dtpe) {
            return null;
        }
        LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
        LocalTime parsedTime = LocalTime.from(accessor);
        LocalDate earliest = now.toLocalDate();
        if (parsedTime.isBefore(now.toLocalTime())) {
            earliest = earliest.plusDays(1);
        }
        return resolveYearMonthDomDow(earliest, accessor).atTime(parsedTime);
    }
    
    private static LocalDate resolveYearMonthDomDow(LocalDate earliest, TemporalAccessor accessor) {
        if (accessor.isSupported(ChronoField.YEAR)) {
            Year parsedYear = Year.from(accessor);
            if (parsedYear.isBefore(Year.from(earliest))) {
                return null;
            }
            return resolveMonthDomDow(parsedYear, earliest, accessor);
        } else {
            Year candidateYear = Year.from(earliest);
            while (true) {
                LocalDate resolved = resolveMonthDomDow(candidateYear, earliest, accessor);
                if (resolved != null) {
                    return resolved;
                }
                candidateYear = candidateYear.plusYears(1);
            }
        }
    }
    
    private static LocalDate resolveMonthDomDow(Year year, LocalDate earliest, TemporalAccessor accessor) {
        if (accessor.isSupported(ChronoField.MONTH_OF_YEAR)) {
            YearMonth knownYm = year.atMonth(accessor.get(ChronoField.MONTH_OF_YEAR));
            if (knownYm.isBefore(YearMonth.from(earliest))) {
                return null;
            }
            return resolveDomDow(knownYm, earliest, accessor);
        } else {
            YearMonth candidateYearMonth = YearMonth.from(earliest);
            if (candidateYearMonth.getYear() < year.getValue()) {
                candidateYearMonth = year.atMonth(Month.JANUARY);
            }
            while (candidateYearMonth.getYear() == year.getValue()) {
                LocalDate resolved = resolveDomDow(candidateYearMonth, earliest, accessor);
                if (resolved != null) {
                    return resolved;
                }
                candidateYearMonth = candidateYearMonth.plusMonths(1);
            }
            return null;
        }
    }
    
    private static LocalDate resolveDomDow(YearMonth ym, LocalDate earliest, TemporalAccessor accessor) {
        if (accessor.isSupported(ChronoField.DAY_OF_MONTH)) {
            int dayOfMonth = accessor.get(ChronoField.DAY_OF_MONTH);
            if (dayOfMonth > ym.lengthOfMonth()) {
                return null;
            }
            LocalDate resolved = ym.atDay(dayOfMonth);
            if (resolved.isBefore(earliest)) {
                return null;
            } else {
                return resolveDow(resolved, accessor);
            }
        } else {
            LocalDate candidateDate = earliest;
            if (YearMonth.from(earliest).isBefore(ym)) {
                candidateDate = ym.atDay(1);
            }
            while (YearMonth.from(candidateDate).equals(ym)) {
                LocalDate resolved = resolveDow(candidateDate, accessor);
                if (resolved != null) {
                    return resolved;
                }
                candidateDate = candidateDate.plusDays(1);
            }
            return null;
        }
    }
    
    private static LocalDate resolveDow(LocalDate date, TemporalAccessor accessor) {
        if (accessor.isSupported(ChronoField.DAY_OF_WEEK)) {
            if (date.getDayOfWeek().getValue() == accessor.get(ChronoField.DAY_OF_WEEK)) {
                return date;
            } else {
                return null;
            }
        } else {
            return date;
        }
    }
    

    Let’s try it out:

        String input = "Monday 12:00 AM";
        // get the next time that matches this pattern
        LocalDateTime time = next(input);
        System.out.println(time);
    

    Output when I ran just now (Monday Januar 11, 2021 in the evening):

    2021-01-18T00:00

    So next Monday. Looks right.

    For a different example, showing that leap years are respected:

        String input = "Wednesday 29 February 12:00 AM";
    

    2040-02-29T00:00

    There are most probably bugs in my code, but the basic idea is working.

    The time of day poses no problem. The challenge is with the date. I am using the time of day to determine whether today’s date is an earliest candidate. If the time now is already past the time in the string, the earliest possible date is tomorrow. For your example string, Monday 12:00 AM, this will practically always be the case: it is always after 12 midnight.

    You had got an ambiguity in Monday 25 12:00 AM since 25 may be a year (a couple of millennia ago) or a day of month. I solved it by insisting on a four digit year. So if a number in the beginning or right after a day of week has four digits, it’s a year, otherwise it’s a day of month. The formatter I use looks funny, the year comes twice. I needed this to force the parsing to try year before trying day of month, or it would sometimes have taken a four digit number to be day of month. This in turn means that the formatter accepts a few formats too many. I figure it won’t be a problem in practice.