Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.List;

import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern;
Expand Down Expand Up @@ -96,10 +98,30 @@ private static OccurrenceInfo getWeeklyPreviousOccurrence(TimeWindowFilterSettin
final ZonedDateTime firstDayOfFirstWeek = start.minusDays(
TimeWindowUtils.getPassedWeekDays(start.getDayOfWeek(), pattern.getFirstDayOfWeek()));

final long numberOfInterval = Duration.between(firstDayOfFirstWeek, now).toSeconds()
/ Duration.ofDays((long) interval * RecurrenceConstants.DAYS_PER_WEEK).toSeconds();
final ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays(
// Use calendar-based week calculation to avoid DST-related undercounting
// Convert both dates to the same zone for accurate week counting
final ZonedDateTime alignedNow = now.withZoneSameInstant(firstDayOfFirstWeek.getZone());
final long numberOfInterval = ChronoUnit.WEEKS.between(firstDayOfFirstWeek, alignedNow) / interval;
ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays(
numberOfInterval * (interval * RecurrenceConstants.DAYS_PER_WEEK));

// Handle DST transitions: If the calculated week start has a fixed offset (ZoneOffset)
// and 'now' has a region zone, check if they represent the same geographic location.
// We do this by checking if the fixed offset matches what the region zone's offset
// was at the *original start time*. If it matches, they're in the same timezone,
// and we should convert to the region zone for DST-aware comparisons.
if (firstDayOfMostRecentOccurringWeek.getZone() instanceof ZoneOffset
&& !(now.getZone() instanceof ZoneOffset)) {
// Check if the fixed offset matches the region zone's offset at the *start* instant
// (not at firstDayOfMostRecentOccurringWeek's instant, which might have crossed DST)
ZoneOffset offsetAtStart = now.getZone().getRules().getOffset(start.toInstant());
if (start.getOffset().equals(offsetAtStart)) {
// Same geographic location, convert to region zone for DST-aware comparisons
firstDayOfMostRecentOccurringWeek = firstDayOfMostRecentOccurringWeek
.withZoneSameLocal(now.getZone());
}
}
Comment thread
mrm9084 marked this conversation as resolved.

final List<DayOfWeek> sortedDaysOfWeek = TimeWindowUtils.sortDaysOfWeek(pattern.getDaysOfWeek(), pattern.getFirstDayOfWeek());
final int maxDayOffset = TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(sortedDaysOfWeek.size() - 1), pattern.getFirstDayOfWeek());
final int minDayOffset = TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(0), pattern.getFirstDayOfWeek());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import java.time.DayOfWeek;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
Expand All @@ -22,8 +23,19 @@

public class RecurrenceValidator {
public static void validateSettings(TimeWindowFilterSettings settings) {
validateSettings(settings, ZonedDateTime.now(ZoneOffset.UTC));
}

/**
* Validates recurrence settings with a specified reference time.
* Public for testing purposes to allow deterministic validation.
*
* @param settings The time window filter settings to validate
* @param referenceTime The reference time to use for "today" in validation calculations
*/
public static void validateSettings(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) {
validateRecurrenceRequiredParameter(settings);
validateRecurrencePattern(settings);
validateRecurrencePattern(settings, referenceTime);
validateRecurrenceRange(settings);
}

Expand Down Expand Up @@ -53,13 +65,13 @@ private static void validateRecurrenceRequiredParameter(TimeWindowFilterSettings
}
}

private static void validateRecurrencePattern(TimeWindowFilterSettings settings) {
private static void validateRecurrencePattern(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) {
final RecurrencePatternType patternType = settings.getRecurrence().getPattern().getType();

if (patternType == RecurrencePatternType.DAILY) {
validateDailyRecurrencePattern(settings);
} else {
validateWeeklyRecurrencePattern(settings);
validateWeeklyRecurrencePattern(settings, referenceTime);
}
}

Expand All @@ -76,7 +88,7 @@ private static void validateDailyRecurrencePattern(TimeWindowFilterSettings sett
validateTimeWindowDuration(settings);
}

private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings settings) {
private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) {
validateDaysOfWeek(settings);

// Check whether "Start" is a valid first occurrence
Expand All @@ -90,7 +102,7 @@ private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings set
validateTimeWindowDuration(settings);

// Check whether the time window duration is shorter than the minimum gap between days of week
if (!isDurationCompliantWithDaysOfWeek(settings)) {
if (!isDurationCompliantWithDaysOfWeek(settings, referenceTime)) {
throw new IllegalArgumentException(String.format(RecurrenceConstants.TIME_WINDOW_DURATION_OUT_OF_RANGE, "Recurrence.Pattern.DaysOfWeek"));
}
}
Expand All @@ -103,7 +115,10 @@ private static void validateTimeWindowDuration(TimeWindowFilterSettings settings
final Duration intervalDuration = RecurrencePatternType.DAILY.equals(pattern.getType())
? Duration.ofDays(pattern.getInterval())
: Duration.ofDays((long) pattern.getInterval() * RecurrenceConstants.DAYS_PER_WEEK);
final Duration timeWindowDuration = Duration.between(settings.getStart(), settings.getEnd());
// Convert to UTC to ensure consistent duration calculation across DST transitions
final Duration timeWindowDuration = Duration.between(
settings.getStart().withZoneSameInstant(ZoneOffset.UTC),
settings.getEnd().withZoneSameInstant(ZoneOffset.UTC));
if (timeWindowDuration.compareTo(intervalDuration) > 0) {
throw new IllegalArgumentException(String.format(RecurrenceConstants.TIME_WINDOW_DURATION_OUT_OF_RANGE, "Recurrence.Pattern.Interval"));
}
Expand All @@ -123,35 +138,39 @@ private static void validateEndDate(TimeWindowFilterSettings settings) {
}

/**
* Check whether the duration is shorter than the minimum gap between recurrence of days of week.
*
* @param settings time window filter settings
/**
* Validate if time window duration is shorter than the minimum gap between days of week
* @param settings The settings to validate
* @param referenceTime The reference time to use for "today" in gap calculations
* @return True if the duration is compliant with days of week, false otherwise.
*/
private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSettings settings) {
private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) {
final List<DayOfWeek> daysOfWeek = settings.getRecurrence().getPattern().getDaysOfWeek();
if (daysOfWeek.size() == 1) {
return true;
}

// Get the date of first day of the week
final ZonedDateTime today = ZonedDateTime.now();
// Get the date of first day of the week using provided reference time
final ZonedDateTime today = referenceTime;
final DayOfWeek firstDayOfWeek = settings.getRecurrence().getPattern().getFirstDayOfWeek();
final int offset = TimeWindowUtils.getPassedWeekDays(today.getDayOfWeek(), firstDayOfWeek);
final ZonedDateTime firstDateOfWeek = today.minusDays(offset).truncatedTo(ChronoUnit.DAYS);
final List<DayOfWeek> sortedDaysOfWeek = TimeWindowUtils.sortDaysOfWeek(daysOfWeek, firstDayOfWeek);

// Loop the whole week to get the min gap between the two consecutive recurrences
// Use calendar-based day counting to avoid DST-related issues (23/25 hour elapsed time)
ZonedDateTime date;
ZonedDateTime prevOccurrence = null;
Duration minGap = Duration.ofDays(RecurrenceConstants.DAYS_PER_WEEK);
long minGapDays = RecurrenceConstants.DAYS_PER_WEEK;

for (DayOfWeek day: sortedDaysOfWeek) {
date = firstDateOfWeek.plusDays(TimeWindowUtils.getPassedWeekDays(day, firstDayOfWeek));
if (prevOccurrence != null) {
final Duration currentGap = Duration.between(prevOccurrence, date);
if (currentGap.compareTo(minGap) < 0) {
minGap = currentGap;
// Use ChronoUnit.DAYS to count calendar days, not elapsed time
// This ensures Sunday-Monday is always 1 day (24 hours) regardless of DST
final long currentGapDays = ChronoUnit.DAYS.between(prevOccurrence, date);
if (currentGapDays < minGapDays) {
minGapDays = currentGapDays;
}
}
prevOccurrence = date;
Expand All @@ -161,13 +180,19 @@ private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSetting
// It may across weeks. Check the adjacent week
date = firstDateOfWeek.plusDays(RecurrenceConstants.DAYS_PER_WEEK)
.plusDays(TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(0), firstDayOfWeek));
final Duration currentGap = Duration.between(prevOccurrence, date);
if (currentGap.compareTo(minGap) < 0) {
minGap = currentGap;
// Use ChronoUnit.DAYS to count calendar days, not elapsed time
final long currentGapDays = ChronoUnit.DAYS.between(prevOccurrence, date);
if (currentGapDays < minGapDays) {
minGapDays = currentGapDays;
}
}

final Duration timeWindowDuration = Duration.between(settings.getStart(), settings.getEnd());
final Duration minGap = Duration.ofDays(minGapDays);

// Convert to UTC to ensure consistent duration calculation across DST transitions
final Duration timeWindowDuration = Duration.between(
settings.getStart().withZoneSameInstant(ZoneOffset.UTC),
settings.getEnd().withZoneSameInstant(ZoneOffset.UTC));
return minGap.compareTo(timeWindowDuration) >= 0;
}
}
Loading
Loading