// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.config.application.api; import java.time.DateTimeException; import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoField; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.BiFunction; import java.util.stream.Collectors; import java.util.stream.IntStream; /** * This class represents a window of time for selected hours, days and dates. * * @author mpolden */ public class TimeWindow { private final List days; private final List hours; private final ZoneId zone; private final LocalDateRange dateRange; private TimeWindow(List days, List hours, ZoneId zone, LocalDateRange dateRange) { this.days = Objects.requireNonNull(days).stream().distinct().sorted().toList(); this.hours = Objects.requireNonNull(hours).stream().distinct().sorted().toList(); this.zone = Objects.requireNonNull(zone); this.dateRange = Objects.requireNonNull(dateRange); if (days.isEmpty()) throw new IllegalArgumentException("At least one day must be specified"); if (hours.isEmpty()) throw new IllegalArgumentException("At least one hour must be specified"); for (var day : days) { if (!dateRange.days().contains(day)) { throw new IllegalArgumentException("Invalid day: " + dateRange + " does not contain " + day); } } } /** Returns days in this time window */ public List days() { return days; } /** Returns hours in this time window */ public List hours() { return hours; } /** Returns the time zone of this time window */ public ZoneId zone() { return zone; } /** Returns the date range of this time window applies to */ public LocalDateRange dateRange() { return dateRange; } /** Returns whether the given instant is in this time window */ public boolean includes(Instant instant) { LocalDateTime dt = LocalDateTime.ofInstant(instant, zone); return days.contains(dt.getDayOfWeek()) && hours.contains(dt.getHour()) && dateRange.includes(dt.toLocalDate()); } @Override public String toString() { return "time window for hour(s) " + hours.toString() + " on " + days.stream().map(DayOfWeek::name) .map(String::toLowerCase) .toList() + " in time zone " + zone + " and " + dateRange.toString(); } /** Parse a time window from the given day, hour and time zone specification */ public static TimeWindow from(String daySpec, String hourSpec, String zoneSpec, String dateStart, String dateEnd) { LocalDateRange dateRange = LocalDateRange.from(dateStart, dateEnd); List days = daySpec.isEmpty() ? List.copyOf(dateRange.days()) // Default to the days contained in the date range : parse(daySpec, TimeWindow::parseDays); List hours = hourSpec.isEmpty() ? IntStream.rangeClosed(0, 23).boxed().toList() // All hours by default : parse(hourSpec, TimeWindow::parseHours); ZoneId zone = zoneFrom(zoneSpec.isEmpty() ? "UTC" : zoneSpec); return new TimeWindow(days, hours, zone, dateRange); } /** Parse a specification, e.g. "1,4-5", using the given value parser */ private static List parse(String spec, BiFunction> valueParser) { List values = new ArrayList<>(); String[] parts = spec.split(","); for (String part : parts) { if (part.contains("-")) { String[] startAndEnd = part.split("-"); if (startAndEnd.length != 2) { throw new IllegalArgumentException("Invalid range '" + part + "'"); } values.addAll(valueParser.apply(startAndEnd[0], startAndEnd[1])); } else { values.addAll(valueParser.apply(part, part)); } } return Collections.unmodifiableList(values); } /** Returns a list of all hours occurring between startInclusive and endInclusive */ private static List parseHours(String startInclusive, String endInclusive) { int start = hourFrom(startInclusive); int end = hourFrom(endInclusive); if (end < start) { throw new IllegalArgumentException(String.format("Invalid hour range '%s-%s'", startInclusive, endInclusive)); } return IntStream.rangeClosed(start, end).boxed() .toList(); } /** Returns a list of all days occurring between startInclusive and endInclusive */ private static List parseDays(String startInclusive, String endInclusive) { DayOfWeek start = dayFrom(startInclusive); DayOfWeek end = dayFrom(endInclusive); if (end.getValue() < start.getValue()) { throw new IllegalArgumentException(String.format("Invalid day range '%s-%s'", startInclusive, endInclusive)); } return IntStream.rangeClosed(start.getValue(), end.getValue()).boxed() .map(DayOfWeek::of) .toList(); } /** Parse day of week from string */ private static DayOfWeek dayFrom(String day) { return Arrays.stream(DayOfWeek.values()) .filter(dayOfWeek -> day.length() >= 3 && dayOfWeek.name().toLowerCase().startsWith(day)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("Invalid day '" + day + "'")); } /** Parse hour from string */ private static int hourFrom(String hour) { try { return ChronoField.HOUR_OF_DAY.checkValidIntValue(Integer.parseInt(hour)); } catch (DateTimeException|NumberFormatException e) { throw new IllegalArgumentException("Invalid hour '" + hour + "'", e); } } /** Parse time zone from string */ private static ZoneId zoneFrom(String zone) { try { return ZoneId.of(zone); } catch (DateTimeException e) { throw new IllegalArgumentException("Invalid time zone '" + zone + "'", e); } } /** A range of local dates, which may be unbounded */ public static class LocalDateRange { private final Optional start; private final Optional end; private LocalDateRange(Optional start, Optional end) { this.start = Objects.requireNonNull(start); this.end = Objects.requireNonNull(end); if (start.isPresent() && end.isPresent() && start.get().isAfter(end.get())) { throw new IllegalArgumentException("Invalid date range: start date " + start.get() + " is after end date " + end.get()); } } /** Returns the starting date of this (inclusive), if any */ public Optional start() { return start; } /** Returns the ending date of this (inclusive), if any */ public Optional end() { return end; } /** Return days of week found in this range */ private Set days() { if (start.isEmpty() || end.isEmpty()) return EnumSet.allOf(DayOfWeek.class); Set days = EnumSet.noneOf(DayOfWeek.class); for (LocalDate date = start.get(); !date.isAfter(end.get()) && days.size() < 7; date = date.plusDays(1)) { days.add(date.getDayOfWeek()); } return days; } /** Returns whether includes the given date */ private boolean includes(LocalDate date) { if (start.isPresent() && date.isBefore(start.get())) return false; if (end.isPresent() && date.isAfter(end.get())) return false; return true; } @Override public String toString() { return "date range [" + start.map(LocalDate::toString).orElse("any date") + ", " + end.map(LocalDate::toString).orElse("any date") + "]"; } private static LocalDateRange from(String start, String end) { try { return new LocalDateRange(optionalDate(start), optionalDate(end)); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Could not parse date range '" + start + "' and '" + end + "'", e); } } private static Optional optionalDate(String date) { return Optional.of(date).filter(s -> !s.isEmpty()).map(LocalDate::parse); } } }