3

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");
Furgle
  • 61
  • 5
  • 1
    So Monday matches Monday but Friday matches Monday and not Friday, I am confused – Joakim Danielson Jan 11 '21 at 21:27
  • 1
    I think you need to define what you mean by "matches". Do you want the next business day's same time? – Dr Dave Jan 11 '21 at 21:39
  • Makes sense to me - it's a bit like Cron - give me the next Monday 12:00 am, so the answer now (at least for me in the UK) would be 2021-01-18T00:00:00 – Chris Jan 11 '21 at 21:55
  • If I wasn't about to go to bed I'd have a think about an answer, but as mentioned, I'm in UK, it's late for someone my age. Expect you'll have an answer before long. If the input was always day and time I guess you could do a simple addition based on day number but you want the input to be flexible I guess – Chris Jan 11 '21 at 21:58
  • Btw, `[]` characters are seen as literals in the parser, not optional sections. That being said, your format currently doesn't match the input – OneCricketeer Jan 11 '21 at 22:00
  • `I can't just do LocalDateTime.from(accessor) because there might not be a year, month, or day of month specified in the input.` - If this is the case then simply have your code add the current data/time components of when the input was supplied. – DevilsHnd - 退職した Jan 11 '21 at 22:01
  • Or how about using a Cron library eg https://stackoverflow.com/a/19781434/2568649 though not sure if they support day of week, crontab does but not sure about these implementations, I vaguely remember looking when I had a similar problem (in my case it was about receiving an input and responding by creating an 'alarm' at the next point in time matching a configured expression like '10.00 first Tuesday of the month'. I think it was the 'first Tuesday of month ' that was supported by crontab but not cron4j so maybe it will do what you need – Chris Jan 11 '21 at 22:02
  • Is your question about parsing the wide range of potential user input, or about generating new times once you have the starting time? – erickson Jan 11 '21 at 22:07
  • @DevilsHnd The next time might not have the same date/time components as the current time. For example if the current date is Sunday January 31st and the input is "Monday 12:00 AM", the resulting LocalDateTime should be in February and not January – Furgle Jan 11 '21 at 22:11
  • @erickson Generating new times – Furgle Jan 11 '21 at 22:12
  • 1
    @OneCricketeer `[]` characters should for optional sections: https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html#patterns – Furgle Jan 11 '21 at 22:27
  • Another way of thinking about it is that what you are trying to do is the same thing quartz does when calculating the next fire time for a Cron timer. You could have a look at the source to see they do it (as long as you can work with standard Cron format in the input, or you can figure out how to convert their Cron parsing to your preferred format) – Chris Jan 11 '21 at 22:31

2 Answers2

3

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.

Ole V.V.
  • 81,772
  • 15
  • 137
  • 161
2

Provided your input is well formatted and is always in English, you could split your input at the first space and use it as follows:

import java.time.DayOfWeek;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.Locale;

public class Example {

    public static void main(String[] args) {
        LocalDateTime desiredDay = getNextDayTime("Friday 12:00 AM");

        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("dd.MM.yyyy hh:mm a");
        System.out.println(dtf.format(desiredDay));
    }
    public static LocalDateTime getNextDayTime(String input){
        String[] splited = input.split(" ", 2);
        LocalTime localTime = LocalTime.parse(splited[1], DateTimeFormatter.ofPattern("hh:mm a", Locale.US));
        LocalDateTime dateTime = LocalDateTime.now().with(localTime);
        LocalDateTime desiredDay = dateTime.with(TemporalAdjusters.next(DayOfWeek.valueOf(splited[0].toUpperCase())));
        return desiredDay;
    }
}
Eritrean
  • 15,851
  • 3
  • 22
  • 28
  • 1
    This would only work for input matching `eeee h:m a`. I would like this to work for any string that matches the pattern `[eeee ][d ][MMMM ][u ]h:m a` – Furgle Jan 11 '21 at 22:30