feat: add recurring reservation expansion
This commit is contained in:
1 parent
9459327b88
commit
f266243a39
5 files changed
+229
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
package com.slotline.recurrence;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record Occurrence(Instant start, Instant end) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.slotline.recurrence;
|
||||||
|
|
||||||
|
public class RecurrenceException extends RuntimeException {
|
||||||
|
|
||||||
|
public RecurrenceException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.slotline.recurrence;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RecurrenceExpander {
|
||||||
|
|
||||||
|
public static final int maxOccurrences = 366;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter untilFormat = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'");
|
||||||
|
|
||||||
|
private static final Map<String, DayOfWeek> byDayCodes = Map.of(
|
||||||
|
"MO", DayOfWeek.MONDAY,
|
||||||
|
"TU", DayOfWeek.TUESDAY,
|
||||||
|
"WE", DayOfWeek.WEDNESDAY,
|
||||||
|
"TH", DayOfWeek.THURSDAY,
|
||||||
|
"FR", DayOfWeek.FRIDAY,
|
||||||
|
"SA", DayOfWeek.SATURDAY,
|
||||||
|
"SU", DayOfWeek.SUNDAY);
|
||||||
|
|
||||||
|
public List<Occurrence> expand(Instant firstStart, Instant firstEnd, String rrule, ZoneId zone) {
|
||||||
|
Duration duration = Duration.between(firstStart, firstEnd);
|
||||||
|
if (duration.isZero() || duration.isNegative()) {
|
||||||
|
throw new RecurrenceException("recurrence slot duration must be positive");
|
||||||
|
}
|
||||||
|
Map<String, String> parts = parse(rrule);
|
||||||
|
String frequency = required(parts, "FREQ");
|
||||||
|
int interval = parseInterval(parts.get("INTERVAL"));
|
||||||
|
Integer count = parseCount(parts.get("COUNT"));
|
||||||
|
Instant until = parseUntil(parts.get("UNTIL"));
|
||||||
|
if (count == null && until == null) {
|
||||||
|
throw new RecurrenceException("recurrence must specify COUNT or UNTIL");
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (frequency) {
|
||||||
|
case "DAILY" -> expandFixedStep(firstStart, duration, zone, interval, count, until, true);
|
||||||
|
case "WEEKLY" -> parts.containsKey("BYDAY")
|
||||||
|
? expandWeeklyByDay(firstStart, duration, zone, interval, count, until, parseByDay(parts.get("BYDAY")))
|
||||||
|
: expandFixedStep(firstStart, duration, zone, interval, count, until, false);
|
||||||
|
default -> throw new RecurrenceException("unsupported FREQ '" + frequency + "'");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Occurrence> expandFixedStep(Instant firstStart, Duration duration, ZoneId zone, int interval,
|
||||||
|
Integer count, Instant until, boolean daily) {
|
||||||
|
List<Occurrence> occurrences = new ArrayList<>();
|
||||||
|
ZonedDateTime cursor = firstStart.atZone(zone);
|
||||||
|
while (true) {
|
||||||
|
Instant start = cursor.toInstant();
|
||||||
|
if (until != null && start.isAfter(until)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
occurrences.add(new Occurrence(start, start.plus(duration)));
|
||||||
|
if (reachedLimit(occurrences.size(), count)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = daily ? cursor.plusDays(interval) : cursor.plusWeeks(interval);
|
||||||
|
}
|
||||||
|
return occurrences;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Occurrence> expandWeeklyByDay(Instant firstStart, Duration duration, ZoneId zone, int interval,
|
||||||
|
Integer count, Instant until, List<DayOfWeek> days) {
|
||||||
|
List<Occurrence> occurrences = new ArrayList<>();
|
||||||
|
ZonedDateTime firstZoned = firstStart.atZone(zone);
|
||||||
|
LocalTime timeOfDay = firstZoned.toLocalTime();
|
||||||
|
LocalDate firstDate = firstZoned.toLocalDate();
|
||||||
|
LocalDate baseMonday = firstDate.minusDays(firstDate.getDayOfWeek().getValue() - 1L);
|
||||||
|
|
||||||
|
int weekBlock = 0;
|
||||||
|
while (weekBlock <= maxOccurrences) {
|
||||||
|
LocalDate weekMonday = baseMonday.plusWeeks((long) weekBlock * interval);
|
||||||
|
for (DayOfWeek day : days) {
|
||||||
|
LocalDate date = weekMonday.plusDays(day.getValue() - 1L);
|
||||||
|
Instant start = ZonedDateTime.of(date, timeOfDay, zone).toInstant();
|
||||||
|
if (start.isBefore(firstStart)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (until != null && start.isAfter(until)) {
|
||||||
|
return occurrences;
|
||||||
|
}
|
||||||
|
occurrences.add(new Occurrence(start, start.plus(duration)));
|
||||||
|
if (reachedLimit(occurrences.size(), count)) {
|
||||||
|
return occurrences;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weekBlock++;
|
||||||
|
}
|
||||||
|
return occurrences;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean reachedLimit(int size, Integer count) {
|
||||||
|
if (count != null && size >= count) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return size >= maxOccurrences;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> parse(String rrule) {
|
||||||
|
if (rrule == null || rrule.isBlank()) {
|
||||||
|
throw new RecurrenceException("recurrence rule is empty");
|
||||||
|
}
|
||||||
|
Map<String, String> parts = new HashMap<>();
|
||||||
|
for (String token : rrule.trim().toUpperCase().split(";")) {
|
||||||
|
if (token.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int split = token.indexOf('=');
|
||||||
|
if (split <= 0) {
|
||||||
|
throw new RecurrenceException("malformed recurrence token '" + token + "'");
|
||||||
|
}
|
||||||
|
parts.put(token.substring(0, split), token.substring(split + 1));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String required(Map<String, String> parts, String key) {
|
||||||
|
String value = parts.get(key);
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new RecurrenceException("recurrence rule is missing " + key);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parseInterval(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
int interval = parsePositive(raw, "INTERVAL");
|
||||||
|
return interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer parseCount(String raw) {
|
||||||
|
return raw == null ? null : parsePositive(raw, "COUNT");
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parsePositive(String raw, String key) {
|
||||||
|
try {
|
||||||
|
int value = Integer.parseInt(raw.trim());
|
||||||
|
if (value < 1) {
|
||||||
|
throw new RecurrenceException(key + " must be at least 1");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (NumberFormatException notANumber) {
|
||||||
|
throw new RecurrenceException(key + " must be an integer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant parseUntil(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDateTime.parse(raw.trim(), untilFormat).toInstant(ZoneOffset.UTC);
|
||||||
|
} catch (DateTimeParseException malformed) {
|
||||||
|
throw new RecurrenceException("UNTIL must be UTC basic format yyyyMMdd'T'HHmmss'Z'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DayOfWeek> parseByDay(String raw) {
|
||||||
|
List<DayOfWeek> days = Arrays.stream(raw.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.map(code -> {
|
||||||
|
DayOfWeek day = byDayCodes.get(code);
|
||||||
|
if (day == null) {
|
||||||
|
throw new RecurrenceException("unknown BYDAY code '" + code + "'");
|
||||||
|
}
|
||||||
|
return day;
|
||||||
|
})
|
||||||
|
.distinct()
|
||||||
|
.sorted(Comparator.comparingInt(DayOfWeek::getValue))
|
||||||
|
.toList();
|
||||||
|
if (days.isEmpty()) {
|
||||||
|
throw new RecurrenceException("BYDAY must list at least one day");
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.slotline.reservation.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record RecurringReservationRequest(
|
||||||
|
@NotNull Long resourceId,
|
||||||
|
@NotNull Instant startTime,
|
||||||
|
@NotNull Instant endTime,
|
||||||
|
@NotBlank String rrule) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.slotline.reservation.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record RecurringReservationResponse(String seriesId, int count, List<ReservationResponse> reservations) {
|
||||||
|
}
|
||||||
Reference in new issue
Block a user