All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.opentripplanner.model.calendar.openinghours.OHCalendarBuilder Maven / Gradle / Ivy

There is a newer version: 2.6.0
Show newest version
package org.opentripplanner.model.calendar.openinghours;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.Month;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.opentripplanner.transit.model.framework.Deduplicator;

public class OHCalendarBuilder {

  private final Deduplicator deduplicator;
  private final LocalDate startOfPeriod;
  private final LocalDate endOfPeriod;
  private final int daysInPeriod;
  private final ZoneId zoneId;
  private final List openingHours = new ArrayList<>();

  public OHCalendarBuilder(
    Deduplicator deduplicator,
    LocalDate startOfPeriod,
    int daysInPeriod,
    ZoneId zoneId
  ) {
    this.deduplicator = deduplicator;
    this.startOfPeriod = startOfPeriod;
    this.endOfPeriod = startOfPeriod.plusDays(daysInPeriod);
    this.daysInPeriod = daysInPeriod;
    this.zoneId = zoneId;
  }

  public OpeningHoursBuilder openingHours(
    String periodDescription,
    LocalTime startTime,
    LocalTime endTime
  ) {
    return new OpeningHoursBuilder(periodDescription, startTime, endTime, false);
  }

  public OpeningHoursBuilder openingHours(
    String periodDescription,
    LocalTime startTime,
    LocalTime endTime,
    boolean isAfterMidnight
  ) {
    return new OpeningHoursBuilder(periodDescription, startTime, endTime, isAfterMidnight);
  }

  public OHCalendar build() {
    // We sort the opening hours for the deduplicator to work a little better and to simplify
    // the check can Enter/Exit later. Even if the opening hours are not on the same dates they
    // will still be sorted in the right order after day filtering
    Collections.sort(openingHours);
    return new OHCalendar(
      startOfPeriod,
      endOfPeriod,
      zoneId,
      deduplicator.deduplicateImmutableList(OpeningHours.class, openingHours)
    );
  }

  /**
   * Record that can be used for builder methods that create new builders that should be returned
   * in addition to the original builder.
   */
  public record OpeningHoursBuilderAndNewBuilders(
    OpeningHoursBuilder originalBuilder,
    List newBuilders
  ) {}

  public class OpeningHoursBuilder {

    private String periodDescription;
    private final LocalTime startTime;
    private final LocalTime endTime;
    private boolean afterMidnight;

    private final BitSet openingDays = new BitSet(daysInPeriod);

    public OpeningHoursBuilder(
      String periodDescription,
      LocalTime startTime,
      LocalTime endTime,
      boolean afterMidnight
    ) {
      this.periodDescription = periodDescription;
      this.startTime = startTime;
      this.endTime = endTime;
      this.afterMidnight = afterMidnight;
    }

    public boolean isAfterMidnight() {
      return afterMidnight;
    }

    /**
     * @return if the builder has any days set as open
     */
    public boolean isEverOn() {
      return !openingDays.isEmpty();
    }

    /**
     * Sets the defined date as open if it's within the defined period. If the builder is set be
     * for times after midnight, the date is shifted one day forward.
     */
    public OpeningHoursBuilder on(LocalDate date) {
      var shiftedDate = date.plusDays(afterMidnight ? 1 : 0);
      if (shiftedDate.isBefore(startOfPeriod) || shiftedDate.isAfter(endOfPeriod)) {
        return this;
      }
      openingDays.set((int) ChronoUnit.DAYS.between(startOfPeriod, shiftedDate));
      return this;
    }

    /**
     * Sets the defined week day to be open on every instance it exists within the defined period.
     * If the builder is set be for times after midnight, the weekday is shifted one day forward.
     */
    public OpeningHoursBuilder on(DayOfWeek dayOfWeek) {
      var shiftedDayOfWeek = dayOfWeek.plus(afterMidnight ? 1 : 0);
      // This counts how many days there are in between the startOfPeriod and
      // when the specified dayOfWeek occurs for the first time.
      int rawWeekDayDifference =
        shiftedDayOfWeek.getValue() - startOfPeriod.getDayOfWeek().getValue();
      int firstOccurrenceDaysFromStart = rawWeekDayDifference >= 0
        ? rawWeekDayDifference
        : 7 - Math.abs(rawWeekDayDifference);

      for (int i = firstOccurrenceDaysFromStart; i < daysInPeriod; i += 7) {
        openingDays.set(i);
      }
      return this;
    }

    /**
     * Sets every weekday in the range to be open on every instance they exist within the defined period.
     * The range is inclusive in both ends.
     * If the builder is set be for times after midnight, the weekdays are shifted one day forward.
     */
    public OpeningHoursBuilder on(DayOfWeek fromDayOfWeek, DayOfWeek untilDayOfWeek) {
      if (fromDayOfWeek == null) {
        return this;
      }
      if (untilDayOfWeek == null) {
        on(fromDayOfWeek);
        return this;
      }

      int untilAdjusted = fromDayOfWeek.getValue() > untilDayOfWeek.getValue()
        ? untilDayOfWeek.getValue() + 7
        : untilDayOfWeek.getValue();
      for (int i = fromDayOfWeek.getValue(); i <= untilAdjusted; i++) {
        int dayValue = i > 7 ? i - 7 : i;
        on(DayOfWeek.of(dayValue));
      }
      return this;
    }

    /**
     * Sets every weekday in the range to be open on every instance they exist within the defined period
     * on the defined month range. Both ranges are inclusive in both ends.
     * If the builder is set be for times after midnight, the days are shifted one day forward
     * so that first day of the first month is never on but the first day after the last month can be.
     */
    public OpeningHoursBuilder on(
      Month fromMonth,
      Month untilMonth,
      DayOfWeek fromDayOfWeek,
      DayOfWeek untilDayOfWeek
    ) {
      if (fromMonth == null || fromDayOfWeek == null) {
        return this;
      }

      Set months = new HashSet<>();
      if (untilMonth == null) {
        months.add(fromMonth);
      } else {
        int untilMonthAdjusted = fromMonth.getValue() > untilMonth.getValue()
          ? untilMonth.getValue() + 12
          : untilMonth.getValue();
        for (int i = fromMonth.getValue(); i <= untilMonthAdjusted; i++) {
          int monthValue = i > 12 ? i - 12 : i;
          months.add(Month.of(monthValue));
        }
      }

      Set daysOfWeek = new HashSet<>();
      if (untilDayOfWeek == null) {
        daysOfWeek.add(fromDayOfWeek);
      } else {
        int untilDayAdjusted = fromDayOfWeek.getValue() > untilDayOfWeek.getValue()
          ? untilDayOfWeek.getValue() + 7
          : untilDayOfWeek.getValue();
        for (int i = fromDayOfWeek.getValue(); i <= untilDayAdjusted; i++) {
          int dayValue = i > 7 ? i - 7 : i;
          daysOfWeek.add(DayOfWeek.of(dayValue));
        }
      }

      var dateToProcess = afterMidnight ? startOfPeriod.minusDays(1) : startOfPeriod;
      int i = 0;
      while (i < daysInPeriod) {
        if (months.contains(dateToProcess.getMonth())) {
          if (daysOfWeek.contains(dateToProcess.getDayOfWeek())) {
            openingDays.set(i);
          }
          dateToProcess = dateToProcess.plusDays(1);
          i += 1;
        } else {
          int daysToSkip =
            YearMonth.of(dateToProcess.getYear(), dateToProcess.getMonth()).lengthOfMonth() -
            dateToProcess.getDayOfMonth() +
            1;
          dateToProcess = dateToProcess.plusDays(daysToSkip);
          i += daysToSkip;
        }
      }
      return this;
    }

    /**
     * Sets every day to be on.
     */
    public OpeningHoursBuilder everyDay() {
      openingDays.set(0, daysInPeriod);
      return this;
    }

    /**
     * Sets the days that are on in the given {@link OpeningHoursBuilder} to be off in this builder
     * and updates this builder's description to reflect that. The provided builder is unmodified.
     * If the builder is set be for times after midnight, we check if the previous day is set
     * in the provided bitset.
     */
    public OpeningHoursBuilder offWithTimeShift(OpeningHoursBuilder otherBuilder) {
      BitSet daysOff = otherBuilder.getOpeningDays();
      String offDescription = otherBuilder.getPeriodDescription();
      if (afterMidnight) {
        boolean intersects = false;
        // Java doesn't seem to have operations for shifting bits
        for (int i = 1; i < daysInPeriod; i++) {
          if (openingDays.get(i) && daysOff.get(i - 1)) {
            intersects = true;
            openingDays.clear(i);
          }
          if (intersects) {
            appendDescription(" except " + offDescription);
          }
        }
      } else {
        off(daysOff, offDescription);
      }
      return this;
    }

    /**
     * Edits this builder and potentially creates one or two new {@link OpeningHoursBuilder} based
     * on the provided {@link OpeningHoursBuilder} according to the following rules:
     * 1. If time spans or days don't overlap with this builder, do nothing and return 0 new builders.
     * 2. if the provided builder covers the whole period from this builder's start time to end time,
     *    edit this builder to be off on the common days and return 0 new builders.
     * 3. if the provided builder covers only the beginning or end part of this builder's opening
     *    period, edit this builder to be off on the common days and return a new builder that is
     *    open on those common days for the remaining part not covered by the provided builder.
     * 4. if the provided builder covers a period in the middle of this builder's opening period,
     *    edit this builder to be off on the common days and return two new builders that are open
     *    on the common days, one for the beginning and one for the end part of this builder's
     *    opening period
     *
     * @return a list of new {@link org.opentripplanner.model.calendar.openinghours.OHCalendarBuilder.OpeningHoursBuilder} created while
     * splitting existing builders.
     */
    public OpeningHoursBuilderAndNewBuilders createBuildersForRelativeComplement(
      OpeningHoursBuilder otherBuilder
    ) {
      LocalTime otherStartTime = otherBuilder.getStartTime();
      LocalTime otherEndTime = otherBuilder.getEndTime();
      if (
        otherEndTime.equals(startTime) ||
        otherEndTime.isBefore(startTime) ||
        endTime.equals(otherStartTime) ||
        endTime.isBefore(otherStartTime)
      ) {
        return new OpeningHoursBuilderAndNewBuilders(this, List.of());
      }
      String offDescription = otherBuilder.getPeriodDescription();
      if (
        (otherStartTime.isBefore(startTime) || otherStartTime.equals(startTime)) &&
        ((endTime.isBefore(otherEndTime) || endTime.equals(otherEndTime)))
      ) {
        off(otherBuilder.getOpeningDays(), offDescription);
        return new OpeningHoursBuilderAndNewBuilders(this, List.of());
      }
      BitSet commonDays = this.getCommonDays(otherBuilder);
      if (commonDays.isEmpty()) {
        return new OpeningHoursBuilderAndNewBuilders(this, List.of());
      }
      String newDescription = String.format(
        "Days overlapping between %s and %s",
        getPeriodDescription(),
        otherBuilder.getPeriodDescription()
      );
      if (otherStartTime.equals(startTime) || otherStartTime.isBefore(startTime)) {
        var newOpeningHoursBuilder = openingHours(newDescription, otherEndTime, endTime);
        newOpeningHoursBuilder.on(commonDays);
        off(commonDays, offDescription);
        return new OpeningHoursBuilderAndNewBuilders(this, List.of(newOpeningHoursBuilder));
      }
      if (endTime.equals(otherEndTime) || endTime.isBefore(otherEndTime)) {
        var newOpeningHoursBuilder = openingHours(newDescription, startTime, otherStartTime);
        newOpeningHoursBuilder.on(commonDays);
        off(commonDays, offDescription);
        return new OpeningHoursBuilderAndNewBuilders(this, List.of(newOpeningHoursBuilder));
      }
      var firstNewOpeningHoursBuilder = openingHours(newDescription, startTime, otherStartTime);
      firstNewOpeningHoursBuilder.on(commonDays);
      var secondNewOpeningHoursBuilder = openingHours(newDescription, otherEndTime, endTime);
      secondNewOpeningHoursBuilder.on(commonDays);
      off(commonDays, offDescription);
      return new OpeningHoursBuilderAndNewBuilders(
        this,
        List.of(firstNewOpeningHoursBuilder, secondNewOpeningHoursBuilder)
      );
    }

    /**
     * Adds the opening hours to the {@link OHCalendar} that is being build here.
     */
    public OHCalendarBuilder add() {
      var days = deduplicator.deduplicateBitSet(openingDays);
      var hours = deduplicator.deduplicateObject(
        OpeningHours.class,
        new OpeningHours(periodDescription, startTime, endTime, days)
      );
      openingHours.add(hours);
      return OHCalendarBuilder.this;
    }

    private BitSet getOpeningDays() {
      return openingDays;
    }

    private String getPeriodDescription() {
      return periodDescription;
    }

    private LocalTime getStartTime() {
      return startTime;
    }

    private LocalTime getEndTime() {
      return endTime;
    }

    private BitSet getCommonDays(OpeningHoursBuilder otherBuilder) {
      var openingDaysClone = (BitSet) openingDays.clone();
      openingDaysClone.and(otherBuilder.getOpeningDays());
      return openingDaysClone;
    }

    /**
     * Adds the given description addition to the end of the current description
     */
    private void appendDescription(String descriptionAddition) {
      periodDescription = periodDescription + descriptionAddition;
    }

    /**
     * Sets the days that are true in the given {@link BitSet} to be on without setting any days off.
     * If the builder is set be for times after midnight, the days are not shifted in this case.
     */
    private OpeningHoursBuilder on(BitSet days) {
      if (days.size() != openingDays.size()) {
        return this;
      }
      openingDays.or(days);
      return this;
    }

    /**
     * Sets the days that are true in the given {@link BitSet} to be off.
     * If the builder is set be for times after midnight, the days are not shifted in this case.
     */
    private OpeningHoursBuilder off(BitSet daysOff, String offDescription) {
      if (openingDays.intersects(daysOff)) {
        openingDays.andNot(daysOff);
        appendDescription(" except " + offDescription);
      }
      return this;
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy