Skip to content

Commit b767a4c

Browse files
WIP: evaluation done
1 parent 92f7456 commit b767a4c

File tree

6 files changed

+264
-41
lines changed

6 files changed

+264
-41
lines changed

src/filter/recurrence/evaluator.ts

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,146 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { Recurrence } from "./model.js";
4+
import { RecurrenceSpec, RecurrencePatternType, RecurrenceRangeType, DAYS_PER_WEEK, ONE_DAY_IN_MILLISECONDS } from "./model.js";
5+
import { calculateWeeklyDayOffset, sortDaysOfWeek, getDayOfWeek, addDays } from "./utils.js";
56

6-
export function matchRecurrence(time: Date, recurrence: Recurrence): boolean {
7-
if (time < recurrence.StartTime) {
8-
return false;
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;
922
}
1023
return false;
1124
}
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+
}

src/filter/recurrence/model.ts

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,34 +14,100 @@ export enum DayOfWeek {
1414
Saturday = 6
1515
}
1616

17+
/**
18+
* The recurrence pattern describes the frequency by which the time window repeats
19+
*/
1720
export enum RecurrencePatternType {
21+
/**
22+
* The pattern where the time window will repeat based on the number of days specified by interval between occurrences
23+
*/
1824
Daily,
25+
/**
26+
* The pattern where the time window will repeat on the same day or days of the week, based on the number of weeks between each set of occurrences
27+
*/
1928
Weekly
2029
}
2130

31+
/**
32+
* The recurrence range specifies the date range over which the time window repeats
33+
*/
2234
export enum RecurrenceRangeType {
35+
/**
36+
* The recurrence has no end and repeats on all the days that fit the corresponding pattern
37+
*/
2338
NoEnd,
39+
/**
40+
* The recurrence repeats on all the days that fit the corresponding pattern until or on the specified end date
41+
*/
2442
EndDate,
43+
/**
44+
* The recurrence repeats for the specified number of occurrences that match the pattern
45+
*/
2546
Numbered
2647
}
2748

49+
/**
50+
* The recurrence pattern describes the frequency by which the time window repeats
51+
*/
2852
export type RecurrencePattern = {
29-
Type: RecurrencePatternType;
30-
Interval: number;
31-
DaysOfWeek?: DayOfWeek[];
32-
FirstDayOfWeek?: DayOfWeek;
53+
/**
54+
* The type of the recurrence pattern
55+
*/
56+
type: RecurrencePatternType;
57+
/**
58+
* The number of units between occurrences, where units can be in days or weeks, depending on the pattern type
59+
*/
60+
interval: number;
61+
/**
62+
* The days of the week when the time window occurs, which is only applicable for 'Weekly' pattern
63+
*/
64+
daysOfWeek?: DayOfWeek[];
65+
/**
66+
* The first day of the week, which is only applicable for 'Weekly' pattern
67+
*/
68+
firstDayOfWeek?: DayOfWeek;
3369
};
3470

71+
/**
72+
* The recurrence range describes a date range over which the time window repeats
73+
*/
3574
export type RecurrenceRange = {
36-
Type: RecurrenceRangeType;
37-
EndDate?: Date;
38-
NumberOfOccurrences?: number;
75+
/**
76+
* The type of the recurrence range
77+
*/
78+
type: RecurrenceRangeType;
79+
/**
80+
* The date to stop applying the recurrence pattern, which is only applicable for 'EndDate' range
81+
*/
82+
endDate?: Date;
83+
/**
84+
* The number of times to repeat the time window, which is only applicable for 'Numbered' range
85+
*/
86+
numberOfOccurrences?: number;
3987
};
4088

41-
export type Recurrence = {
42-
StartTime: Date;
43-
EndTime: Date;
44-
Pattern: RecurrencePattern;
45-
Range: RecurrenceRange;
46-
TimeZoneOffset: number;
89+
/**
90+
* Specification defines the recurring time window
91+
*/
92+
export type RecurrenceSpec = {
93+
/**
94+
* The start time of the first/base time window
95+
*/
96+
startTime: Date;
97+
/**
98+
* The duration of each time window in milliseconds
99+
*/
100+
duration: number;
101+
/**
102+
* The recurrence pattern
103+
*/
104+
pattern: RecurrencePattern;
105+
/**
106+
* The recurrence range
107+
*/
108+
range: RecurrenceRange;
109+
/**
110+
* The timezone offset in milliseconds, which helps to determine the day of week of a given date
111+
*/
112+
timezoneOffset: number;
47113
};

src/filter/recurrence/utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,26 @@ export function sortDaysOfWeek(daysOfWeek: DayOfWeek[], firstDayOfWeek: DayOfWee
2424
sortedDaysOfWeek.sort((x, y) => calculateWeeklyDayOffset(x, firstDayOfWeek) - calculateWeeklyDayOffset(y, firstDayOfWeek));
2525
return sortedDaysOfWeek;
2626
}
27+
28+
/**
29+
* Gets the day of week of a given date based on the timezone offset.
30+
* @param date A UTC date
31+
* @param timezoneOffset The timezone offset in milliseconds
32+
* @returns The day of week (0 for Sunday, 1 for Monday, ..., 6 for Saturday)
33+
*/
34+
export function getDayOfWeek(date: Date, timezoneOffset: number): number {
35+
const alignedDate = new Date(date.getTime() + timezoneOffset);
36+
return alignedDate.getUTCDay();
37+
}
38+
39+
/**
40+
* Adds a specified number of days to a given date.
41+
* @param date The date to add days to
42+
* @param days The number of days to add
43+
* @returns The new date
44+
*/
45+
export function addDays(date: Date, days: number): Date {
46+
const result = new Date(date);
47+
result.setDate(result.getDate() + days);
48+
return result;
49+
}

0 commit comments

Comments
 (0)