Skip to content

Commit d95cfb6

Browse files
Recurring time window filter (#73)
* WIP: validation finished * update filename * WIP: evaluation done * add testcases * fix lint * update method name * update * revert change
1 parent 4d859aa commit d95cfb6

15 files changed

+1543
-14
lines changed

src/feature-management/package-lock.json

Lines changed: 119 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/feature-management/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"dev": "rollup --config --watch",
1919
"lint": "eslint src/ test/ --ignore-pattern test/browser/testcases.js",
2020
"fix-lint": "eslint src/ test/ --fix --ignore-pattern test/browser/testcases.js",
21-
"test": "mocha out/*.test.{js,cjs,mjs} --parallel",
21+
"test": "mocha out/test/*.test.{js,cjs,mjs} --parallel",
2222
"test-browser": "npx playwright install chromium && npx playwright test"
2323
},
2424
"repository": {
@@ -47,6 +47,7 @@
4747
"rimraf": "^5.0.5",
4848
"rollup": "^4.22.4",
4949
"rollup-plugin-dts": "^6.1.0",
50+
"sinon": "^18.0.0",
5051
"tslib": "^2.6.2",
5152
"typescript": "^5.3.3"
5253
},
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { RecurrenceSpec, RecurrencePatternType, RecurrenceRangeType, DAYS_PER_WEEK, ONE_DAY_IN_MILLISECONDS } from "./model.js";
5+
import { calculateWeeklyDayOffset, sortDaysOfWeek, getDayOfWeek, addDays } from "./utils.js";
6+
7+
type RecurrenceState = {
8+
previousOccurrence: Date;
9+
numberOfOccurrences: number;
10+
}
11+
12+
/**
13+
* Checks if a provided datetime is within any recurring time window specified by the recurrence information
14+
* @param time A datetime
15+
* @param recurrenceSpec The recurrence spcification
16+
* @returns True if the given time is within any recurring time window; otherwise, false
17+
*/
18+
export function matchRecurrence(time: Date, recurrenceSpec: RecurrenceSpec): boolean {
19+
const recurrenceState = findPreviousRecurrence(time, recurrenceSpec);
20+
if (recurrenceState) {
21+
return time.getTime() < recurrenceState.previousOccurrence.getTime() + recurrenceSpec.duration;
22+
}
23+
return false;
24+
}
25+
26+
/**
27+
* Finds the closest previous recurrence occurrence before the given time according to the recurrence information
28+
* @param time A datetime
29+
* @param recurrenceSpec The recurrence specification
30+
* @returns The recurrence state if any previous occurrence is found; otherwise, undefined
31+
*/
32+
function findPreviousRecurrence(time: Date, recurrenceSpec: RecurrenceSpec): RecurrenceState | undefined {
33+
if (time < recurrenceSpec.startTime) {
34+
return undefined;
35+
}
36+
let result: RecurrenceState;
37+
const pattern = recurrenceSpec.pattern;
38+
if (pattern.type === RecurrencePatternType.Daily) {
39+
result = findPreviousDailyRecurrence(time, recurrenceSpec);
40+
} else if (pattern.type === RecurrencePatternType.Weekly) {
41+
result = findPreviousWeeklyRecurrence(time, recurrenceSpec);
42+
} else {
43+
throw new Error("Unsupported recurrence pattern type.");
44+
}
45+
const { previousOccurrence, numberOfOccurrences } = result;
46+
47+
const range = recurrenceSpec.range;
48+
if (range.type === RecurrenceRangeType.EndDate) {
49+
if (previousOccurrence > range.endDate!) {
50+
return undefined;
51+
}
52+
} else if (range.type === RecurrenceRangeType.Numbered) {
53+
if (numberOfOccurrences > range.numberOfOccurrences!) {
54+
return undefined;
55+
}
56+
}
57+
return result;
58+
}
59+
60+
function findPreviousDailyRecurrence(time: Date, recurrenceSpec: RecurrenceSpec): RecurrenceState {
61+
const startTime = recurrenceSpec.startTime;
62+
const timeGap = time.getTime() - startTime.getTime();
63+
const pattern = recurrenceSpec.pattern;
64+
const numberOfIntervals = Math.floor(timeGap / (pattern.interval * ONE_DAY_IN_MILLISECONDS));
65+
return {
66+
previousOccurrence: addDays(startTime, numberOfIntervals * pattern.interval),
67+
numberOfOccurrences: numberOfIntervals + 1
68+
};
69+
}
70+
71+
function findPreviousWeeklyRecurrence(time: Date, recurrenceSpec: RecurrenceSpec): RecurrenceState {
72+
/*
73+
* Algorithm:
74+
* 1. first find day 0 (d0), it's the day representing the start day on the week of `Start`.
75+
* 2. find start day of the most recent occurring week d0 + floor((time - d0) / (interval * 7)) * (interval * 7)
76+
* 3. if that's over 7 days ago, then previous occurence is the day with the max offset of the last occurring week
77+
* 4. if gotten this far, then the current week is the most recent occurring week:
78+
i. if time > day with min offset, then previous occurence is the day with max offset less than current
79+
ii. if time < day with min offset, then previous occurence is the day with the max offset of previous occurring week
80+
*/
81+
const startTime = recurrenceSpec.startTime;
82+
const startDay = getDayOfWeek(startTime, recurrenceSpec.timezoneOffset);
83+
const pattern = recurrenceSpec.pattern;
84+
const sortedDaysOfWeek = sortDaysOfWeek(pattern.daysOfWeek!, pattern.firstDayOfWeek!);
85+
86+
/*
87+
* Example:
88+
* startTime = 2024-12-11 (Tue)
89+
* pattern.interval = 2 pattern.firstDayOfWeek = Sun pattern.daysOfWeek = [Wed, Sun]
90+
* sortedDaysOfWeek = [Sun, Wed]
91+
* firstDayofStartWeek = 2024-12-08 (Sun)
92+
*
93+
* time = 2024-12-23 (Mon) timeGap = 15 days
94+
* the most recent occurring week: 2024-12-22 ~ 2024-12-28
95+
* number of intervals before the most recent occurring week = 15 / (2 * 7) = 1 (2024-12-08 ~ 2023-12-21)
96+
* number of occurrences before the most recent occurring week = 1 * 2 - 1 = 1 (2024-12-11)
97+
* firstDayOfLastOccurringWeek = 2024-12-22
98+
*/
99+
const firstDayofStartWeek = addDays(startTime, -calculateWeeklyDayOffset(startDay, pattern.firstDayOfWeek!));
100+
const timeGap = time.getTime() - firstDayofStartWeek.getTime();
101+
// number of intervals before the most recent occurring week
102+
const numberOfIntervals = Math.floor(timeGap / (pattern.interval * DAYS_PER_WEEK * ONE_DAY_IN_MILLISECONDS));
103+
// number of occurrences before the most recent occurring week, it is possible to be negative
104+
let numberOfOccurrences = numberOfIntervals * sortedDaysOfWeek.length - sortedDaysOfWeek.indexOf(startDay);
105+
const firstDayOfLatestOccurringWeek = addDays(firstDayofStartWeek, numberOfIntervals * pattern.interval * DAYS_PER_WEEK);
106+
107+
// the current time is out of the last occurring week
108+
if (time > addDays(firstDayOfLatestOccurringWeek, DAYS_PER_WEEK)) {
109+
numberOfOccurrences += sortDaysOfWeek.length;
110+
// day with max offset in the last occurring week
111+
const previousOccurrence = addDays(firstDayOfLatestOccurringWeek, calculateWeeklyDayOffset(sortedDaysOfWeek.at(-1)!, pattern.firstDayOfWeek!));
112+
return {
113+
previousOccurrence: previousOccurrence,
114+
numberOfOccurrences: numberOfOccurrences
115+
};
116+
}
117+
118+
let dayWithMinOffset = addDays(firstDayOfLatestOccurringWeek, calculateWeeklyDayOffset(sortedDaysOfWeek[0], pattern.firstDayOfWeek!));
119+
if (dayWithMinOffset < startTime) {
120+
numberOfOccurrences = 0;
121+
dayWithMinOffset = startTime;
122+
}
123+
let previousOccurrence;
124+
if (time >= dayWithMinOffset) {
125+
// the previous occurence is the day with max offset less than current
126+
previousOccurrence = dayWithMinOffset;
127+
numberOfOccurrences += 1;
128+
const dayWithMinOffsetIndex = sortedDaysOfWeek.indexOf(getDayOfWeek(dayWithMinOffset, recurrenceSpec.timezoneOffset));
129+
for (let i = dayWithMinOffsetIndex + 1; i < sortedDaysOfWeek.length; i++) {
130+
const day = addDays(firstDayOfLatestOccurringWeek, calculateWeeklyDayOffset(sortedDaysOfWeek[i], pattern.firstDayOfWeek!));
131+
if (time < day) {
132+
break;
133+
}
134+
previousOccurrence = day;
135+
numberOfOccurrences += 1;
136+
}
137+
} else {
138+
const firstDayOfPreviousOccurringWeek = addDays(firstDayOfLatestOccurringWeek, -pattern.interval * DAYS_PER_WEEK);
139+
// the previous occurence is the day with the max offset of previous occurring week
140+
previousOccurrence = addDays(firstDayOfPreviousOccurringWeek, calculateWeeklyDayOffset(sortedDaysOfWeek.at(-1)!, pattern.firstDayOfWeek!));
141+
}
142+
return {
143+
previousOccurrence: previousOccurrence,
144+
numberOfOccurrences: numberOfOccurrences
145+
};
146+
}

0 commit comments

Comments
 (0)