Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.ZoneOffset;
import java.util.List;

import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern;
Expand Down Expand Up @@ -98,8 +99,26 @@ private static OccurrenceInfo getWeeklyPreviousOccurrence(TimeWindowFilterSettin

final long numberOfInterval = Duration.between(firstDayOfFirstWeek, now).toSeconds()
/ Duration.ofDays((long) interval * RecurrenceConstants.DAYS_PER_WEEK).toSeconds();
final ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays(
ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays(
numberOfInterval * (interval * RecurrenceConstants.DAYS_PER_WEEK));

Comment on lines 100 to +104
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

numberOfInterval is computed using Duration.between(...).toSeconds() / Duration.ofDays(...).toSeconds(). Around DST transitions this can undercount by 1 when the local wall-clock time matches the weekly boundary but the underlying instants differ by +/-1 hour (e.g., 3 weeks minus 1 hour after spring-forward). This means the “most recent occurring week” can be shifted back a week, which defeats the DST fix.

Consider computing the interval count using calendar-based units (e.g., ChronoUnit.DAYS/WEEKS between LocalDates after aligning now/firstDayOfFirstWeek to the same intended zone), instead of seconds-based duration division.

Copilot uses AI. Check for mistakes.
Comment on lines 100 to +104
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

numberOfInterval is computed using Duration.between(...).toSeconds() / Duration.ofDays(...).toSeconds(), which is DST-sensitive when start is a fixed ZoneOffset but now is a region-based ZoneId. Across a spring-forward transition, the elapsed seconds for an exact N-week boundary can be 1 hour short, causing an off-by-one interval (and a wrong previous occurrence) even after the later zone conversion. Consider basing the interval calculation on calendar weeks/dates in the chosen evaluation zone (e.g., normalize to the target zone first, then use ChronoUnit.WEEKS/ChronoUnit.DAYS on LocalDate) and apply the same approach to the daily path too (it has the same seconds-per-day division issue).

Copilot uses AI. Check for mistakes.
// 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());
}
}

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 Down Expand Up @@ -123,19 +135,20 @@ 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);
Expand Down
Loading