|
| 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