@tuturuuu/utils 0.33.3 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/package.json +6 -4
- package/src/__tests__/api-proxy-bearer-trust.test.ts +3 -0
- package/src/__tests__/api-proxy-guard.test.ts +6 -13
- package/src/__tests__/api-proxy-offline-response.test.ts +48 -0
- package/src/__tests__/api-proxy-test-request.ts +19 -0
- package/src/__tests__/calendar-provider-readonly.test.ts +45 -0
- package/src/__tests__/calendar-recurrence-edit.test.ts +140 -0
- package/src/__tests__/calendar-recurrence.test.ts +479 -0
- package/src/__tests__/daily-operation-download-isolation.test.ts +137 -0
- package/src/__tests__/offline-download-guard.test.ts +340 -0
- package/src/__tests__/permissions.test.tsx +21 -0
- package/src/__tests__/request-workspace.test.ts +63 -0
- package/src/__tests__/tutoring-teacher-hours.test.ts +274 -0
- package/src/__tests__/username-policy.test.ts +70 -0
- package/src/account-benefits-server.ts +43 -0
- package/src/account-benefits.ts +45 -0
- package/src/api-proxy-guard.ts +8 -42
- package/src/api-proxy-read-policy.ts +43 -0
- package/src/calendar-provider-readonly.ts +49 -0
- package/src/calendar-recurrence-edit.ts +148 -0
- package/src/calendar-recurrence-expansion.ts +131 -0
- package/src/calendar-recurrence-local.ts +45 -0
- package/src/calendar-recurrence-pattern.ts +54 -0
- package/src/calendar-recurrence-providers.ts +361 -0
- package/src/calendar-recurrence-validation.ts +180 -0
- package/src/calendar-recurrence.ts +5 -0
- package/src/calendar-utils.ts +41 -3
- package/src/common/metadata.test.ts +0 -6
- package/src/constants.ts +3 -0
- package/src/deployment-vault-permissions.tsx +26 -0
- package/src/desktop-signing-profiles.ts +20 -0
- package/src/google-calendar-import-fence.test.ts +182 -0
- package/src/google-calendar-import-fence.ts +166 -0
- package/src/next-config.ts +8 -1
- package/src/offline-download-guard.ts +160 -0
- package/src/permissions.tsx +2 -10
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/playground-schema.test.ts +119 -0
- package/src/playground-schema.ts +173 -0
- package/src/playground-templates.ts +65 -0
- package/src/release-note-policy-core.cjs +28 -0
- package/src/release-note-policy-core.d.cts +5 -0
- package/src/release-note-policy.json +9 -0
- package/src/release-note-policy.ts +4 -0
- package/src/request-workspace.ts +29 -0
- package/src/seo-policy.json +196 -0
- package/src/seo.test.ts +168 -0
- package/src/seo.ts +49 -0
- package/src/tutoring-teacher-hours.ts +220 -0
- package/src/username-policy.json +87 -0
- package/src/username-policy.ts +18 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
import type {
|
|
3
|
+
CalendarRecurrenceAnchor,
|
|
4
|
+
CalendarRecurrenceRule,
|
|
5
|
+
} from '@tuturuuu/types/primitives/calendar-recurrence';
|
|
6
|
+
import { matchesCalendarRecurrenceDate } from './calendar-recurrence-pattern';
|
|
7
|
+
import {
|
|
8
|
+
CalendarLocalDateTimeSchema,
|
|
9
|
+
validateCalendarRecurrence,
|
|
10
|
+
} from './calendar-recurrence-validation';
|
|
11
|
+
|
|
12
|
+
/** Validates immutable occurrence identity and counts slots before exceptions.
|
|
13
|
+
* Never trust a caller-provided instance index for COUNT or future splits. */
|
|
14
|
+
export function inspectCalendarRecurrenceSlot(input: {
|
|
15
|
+
rule: CalendarRecurrenceRule;
|
|
16
|
+
anchor: CalendarRecurrenceAnchor;
|
|
17
|
+
originalStartLocal: string;
|
|
18
|
+
}): {
|
|
19
|
+
precedingCount: number;
|
|
20
|
+
previousRule: CalendarRecurrenceRule | null;
|
|
21
|
+
remainingRule: CalendarRecurrenceRule;
|
|
22
|
+
} {
|
|
23
|
+
const { rule, anchor } = validateCalendarRecurrence(input.rule, input.anchor);
|
|
24
|
+
const slot = Temporal.PlainDateTime.from(
|
|
25
|
+
CalendarLocalDateTimeSchema.parse(input.originalStartLocal)
|
|
26
|
+
);
|
|
27
|
+
const first = Temporal.PlainDateTime.from(anchor.startLocal);
|
|
28
|
+
if (
|
|
29
|
+
Temporal.PlainDateTime.compare(slot, first) < 0 ||
|
|
30
|
+
!slot.toPlainTime().equals(first.toPlainTime())
|
|
31
|
+
)
|
|
32
|
+
throw new RangeError('Not a series occurrence');
|
|
33
|
+
let precedingCount = 0;
|
|
34
|
+
let scanned = 0;
|
|
35
|
+
for (
|
|
36
|
+
let date = first.toPlainDate();
|
|
37
|
+
Temporal.PlainDate.compare(date, slot.toPlainDate()) <= 0;
|
|
38
|
+
date = date.add({ days: 1 })
|
|
39
|
+
) {
|
|
40
|
+
if (++scanned > 36600)
|
|
41
|
+
throw new RangeError('Recurrence scan exceeds 100 years');
|
|
42
|
+
if (
|
|
43
|
+
rule.end.type === 'until' &&
|
|
44
|
+
Temporal.PlainDate.compare(date, rule.end.date) > 0
|
|
45
|
+
)
|
|
46
|
+
break;
|
|
47
|
+
if (!matchesCalendarRecurrenceDate(date, first.toPlainDate(), rule))
|
|
48
|
+
continue;
|
|
49
|
+
const local = date.toPlainDateTime(first.toPlainTime());
|
|
50
|
+
if (
|
|
51
|
+
!local
|
|
52
|
+
.toZonedDateTime(rule.timeZone, { disambiguation: 'compatible' })
|
|
53
|
+
.toPlainDateTime()
|
|
54
|
+
.equals(local)
|
|
55
|
+
)
|
|
56
|
+
continue;
|
|
57
|
+
if (rule.end.type === 'count' && precedingCount >= rule.end.count) break;
|
|
58
|
+
if (local.equals(slot)) {
|
|
59
|
+
return {
|
|
60
|
+
precedingCount,
|
|
61
|
+
previousRule:
|
|
62
|
+
precedingCount === 0
|
|
63
|
+
? null
|
|
64
|
+
: {
|
|
65
|
+
...rule,
|
|
66
|
+
end: {
|
|
67
|
+
type: 'until',
|
|
68
|
+
date: slot.toPlainDate().subtract({ days: 1 }).toString(),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
remainingRule: {
|
|
72
|
+
...rule,
|
|
73
|
+
...(rule.end.type === 'count'
|
|
74
|
+
? { end: { type: 'count', count: rule.end.count - precedingCount } }
|
|
75
|
+
: {}),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
precedingCount++;
|
|
80
|
+
}
|
|
81
|
+
throw new RangeError('Not a series occurrence');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function calendarAnchorAtSlot(
|
|
85
|
+
anchor: CalendarRecurrenceAnchor,
|
|
86
|
+
originalStartLocal: string
|
|
87
|
+
): CalendarRecurrenceAnchor {
|
|
88
|
+
const start = Temporal.PlainDateTime.from(anchor.startLocal);
|
|
89
|
+
const duration = start.until(anchor.endLocal, { largestUnit: 'days' });
|
|
90
|
+
const slot = Temporal.PlainDateTime.from(
|
|
91
|
+
CalendarLocalDateTimeSchema.parse(originalStartLocal)
|
|
92
|
+
);
|
|
93
|
+
return {
|
|
94
|
+
...anchor,
|
|
95
|
+
startLocal: slot.toString({ smallestUnit: 'second' }),
|
|
96
|
+
endLocal: slot.add(duration).toString({ smallestUnit: 'second' }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Immutable original slot as a UTC instant for provider occurrence lookup. */
|
|
101
|
+
export function calendarRecurrenceSlotInstant(input: {
|
|
102
|
+
rule: CalendarRecurrenceRule;
|
|
103
|
+
anchor: CalendarRecurrenceAnchor;
|
|
104
|
+
originalStartLocal: string;
|
|
105
|
+
}) {
|
|
106
|
+
inspectCalendarRecurrenceSlot(input);
|
|
107
|
+
return Temporal.PlainDateTime.from(input.originalStartLocal)
|
|
108
|
+
.toZonedDateTime(input.rule.timeZone, { disambiguation: 'compatible' })
|
|
109
|
+
.toInstant()
|
|
110
|
+
.toString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Normalize a provider date-time without falling back to the machine timezone. */
|
|
114
|
+
export function calendarProviderDateTimeLocal(
|
|
115
|
+
input: { dateTime: string; timeZone?: string },
|
|
116
|
+
timeZone: string
|
|
117
|
+
) {
|
|
118
|
+
const instant = /(?:Z|[+-]\d{2}:\d{2})$/i.test(input.dateTime)
|
|
119
|
+
? Temporal.Instant.from(input.dateTime)
|
|
120
|
+
: Temporal.PlainDateTime.from(input.dateTime)
|
|
121
|
+
.toZonedDateTime(input.timeZone ?? timeZone, {
|
|
122
|
+
disambiguation: 'compatible',
|
|
123
|
+
})
|
|
124
|
+
.toInstant();
|
|
125
|
+
if (instant.epochNanoseconds % 1_000_000_000n !== 0n)
|
|
126
|
+
throw new RangeError(
|
|
127
|
+
'Provider occurrence has unsupported fractional precision'
|
|
128
|
+
);
|
|
129
|
+
return instant
|
|
130
|
+
.toZonedDateTimeISO(timeZone)
|
|
131
|
+
.toPlainDateTime()
|
|
132
|
+
.toString({ smallestUnit: 'second' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Original slot interval, before moves/cancellations, for coverage fencing. */
|
|
136
|
+
export function calendarRecurrenceSlotBounds(input: {
|
|
137
|
+
rule: CalendarRecurrenceRule;
|
|
138
|
+
anchor: CalendarRecurrenceAnchor;
|
|
139
|
+
originalStartLocal: string;
|
|
140
|
+
}) {
|
|
141
|
+
const start = calendarRecurrenceSlotInstant(input);
|
|
142
|
+
const slot = calendarAnchorAtSlot(input.anchor, input.originalStartLocal);
|
|
143
|
+
const end = Temporal.PlainDateTime.from(slot.endLocal)
|
|
144
|
+
.toZonedDateTime(input.rule.timeZone, { disambiguation: 'compatible' })
|
|
145
|
+
.toInstant()
|
|
146
|
+
.toString();
|
|
147
|
+
return { start, end };
|
|
148
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
import type {
|
|
3
|
+
CalendarRecurrenceAnchor,
|
|
4
|
+
CalendarRecurrenceException,
|
|
5
|
+
CalendarRecurrenceOccurrence,
|
|
6
|
+
CalendarRecurrenceRule,
|
|
7
|
+
} from '@tuturuuu/types/primitives/calendar-recurrence';
|
|
8
|
+
import { matchesCalendarRecurrenceDate } from './calendar-recurrence-pattern';
|
|
9
|
+
import {
|
|
10
|
+
CalendarRecurrenceAnchorSchema,
|
|
11
|
+
CalendarRecurrenceExceptionSchema,
|
|
12
|
+
validateCalendarRecurrence,
|
|
13
|
+
} from './calendar-recurrence-validation';
|
|
14
|
+
|
|
15
|
+
const MAX_SCANNED_DAYS = 36600;
|
|
16
|
+
|
|
17
|
+
/** A bounded recurrence expansion. Count is applied before cancellations/moves.
|
|
18
|
+
* Nonexistent DST wall-clock slots are skipped; folds use the earlier instant.
|
|
19
|
+
* Throws on a scan limit rather than presenting incomplete history as complete. */
|
|
20
|
+
export function expandCalendarRecurrence(args: {
|
|
21
|
+
rule: CalendarRecurrenceRule;
|
|
22
|
+
anchor: CalendarRecurrenceAnchor;
|
|
23
|
+
from: string;
|
|
24
|
+
to: string;
|
|
25
|
+
exceptions?: CalendarRecurrenceException[];
|
|
26
|
+
limit?: number;
|
|
27
|
+
}): { occurrences: CalendarRecurrenceOccurrence[]; truncated: boolean } {
|
|
28
|
+
const { rule, anchor } = validateCalendarRecurrence(args.rule, args.anchor);
|
|
29
|
+
const from = Temporal.Instant.from(args.from);
|
|
30
|
+
const to = Temporal.Instant.from(args.to);
|
|
31
|
+
if (Temporal.Instant.compare(from, to) >= 0)
|
|
32
|
+
throw new RangeError('Invalid recurrence range');
|
|
33
|
+
const limit = args.limit ?? 500;
|
|
34
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
35
|
+
throw new RangeError('Occurrence limit must be 1–1000');
|
|
36
|
+
const start = Temporal.PlainDateTime.from(anchor.startLocal);
|
|
37
|
+
const duration = start.until(anchor.endLocal, { largestUnit: 'days' });
|
|
38
|
+
const first = start.toPlainDate();
|
|
39
|
+
const exceptionBySlot = new Map<string, CalendarRecurrenceException>();
|
|
40
|
+
let stop = to.toZonedDateTimeISO(rule.timeZone).toPlainDate();
|
|
41
|
+
if ((args.exceptions?.length ?? 0) > 1000)
|
|
42
|
+
throw new RangeError('Exception limit exceeds 1000');
|
|
43
|
+
for (const rawException of args.exceptions ?? []) {
|
|
44
|
+
const exception = CalendarRecurrenceExceptionSchema.parse(rawException);
|
|
45
|
+
const slot = Temporal.PlainDateTime.from(
|
|
46
|
+
exception.originalStartLocal
|
|
47
|
+
).toString({ smallestUnit: 'second' });
|
|
48
|
+
if (exceptionBySlot.has(slot))
|
|
49
|
+
throw new RangeError('Duplicate recurrence exception slot');
|
|
50
|
+
if (exception.startLocal || exception.endLocal) {
|
|
51
|
+
CalendarRecurrenceAnchorSchema.parse({
|
|
52
|
+
startLocal: exception.startLocal,
|
|
53
|
+
endLocal: exception.endLocal,
|
|
54
|
+
allDay: anchor.allDay,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
exceptionBySlot.set(slot, exception);
|
|
58
|
+
const date = Temporal.PlainDateTime.from(slot).toPlainDate();
|
|
59
|
+
if (Temporal.PlainDate.compare(date, stop) > 0) stop = date;
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
rule.end.type === 'until' &&
|
|
63
|
+
Temporal.PlainDate.compare(stop, rule.end.date) > 0
|
|
64
|
+
)
|
|
65
|
+
stop = Temporal.PlainDate.from(rule.end.date);
|
|
66
|
+
// An uncounted rule necessarily visits every date through the effective stop.
|
|
67
|
+
// Reject oversized history before constructing thousands of zoned occurrences.
|
|
68
|
+
// COUNT can terminate earlier, so its existing incremental guard remains below.
|
|
69
|
+
if (
|
|
70
|
+
rule.end.type !== 'count' &&
|
|
71
|
+
first.until(stop, { largestUnit: 'days' }).days >= MAX_SCANNED_DAYS
|
|
72
|
+
)
|
|
73
|
+
throw new RangeError('Recurrence scan exceeds 100 years');
|
|
74
|
+
const occurrences: CalendarRecurrenceOccurrence[] = [];
|
|
75
|
+
let count = 0;
|
|
76
|
+
let scanned = 0;
|
|
77
|
+
for (
|
|
78
|
+
let date = first;
|
|
79
|
+
Temporal.PlainDate.compare(date, stop) <= 0;
|
|
80
|
+
date = date.add({ days: 1 })
|
|
81
|
+
) {
|
|
82
|
+
if (++scanned > MAX_SCANNED_DAYS)
|
|
83
|
+
throw new RangeError('Recurrence scan exceeds 100 years');
|
|
84
|
+
if (!matchesCalendarRecurrenceDate(date, first, rule)) continue;
|
|
85
|
+
const local = date.toPlainDateTime(start.toPlainTime());
|
|
86
|
+
const zoned = local.toZonedDateTime(rule.timeZone, {
|
|
87
|
+
disambiguation: 'compatible',
|
|
88
|
+
});
|
|
89
|
+
// RFC 5545 invalid/nonexistent local times do not consume COUNT.
|
|
90
|
+
if (!zoned.toPlainDateTime().equals(local)) continue;
|
|
91
|
+
if (rule.end.type === 'count' && ++count > rule.end.count) break;
|
|
92
|
+
const slot = local.toString({ smallestUnit: 'second' });
|
|
93
|
+
const exception = exceptionBySlot.get(slot);
|
|
94
|
+
if (exception?.cancelled) continue;
|
|
95
|
+
const actualStart = exception?.startLocal
|
|
96
|
+
? Temporal.PlainDateTime.from(exception.startLocal)
|
|
97
|
+
: local;
|
|
98
|
+
const actualEnd = exception?.endLocal
|
|
99
|
+
? Temporal.PlainDateTime.from(exception.endLocal)
|
|
100
|
+
: local.add(duration);
|
|
101
|
+
const startInstant = actualStart
|
|
102
|
+
.toZonedDateTime(rule.timeZone, { disambiguation: 'compatible' })
|
|
103
|
+
.toInstant();
|
|
104
|
+
const endInstant = actualEnd
|
|
105
|
+
.toZonedDateTime(rule.timeZone, { disambiguation: 'compatible' })
|
|
106
|
+
.toInstant();
|
|
107
|
+
if (Temporal.Instant.compare(endInstant, startInstant) <= 0)
|
|
108
|
+
throw new RangeError('Occurrence end must follow start');
|
|
109
|
+
if (
|
|
110
|
+
Temporal.Instant.compare(startInstant, to) >= 0 ||
|
|
111
|
+
Temporal.Instant.compare(endInstant, from) <= 0
|
|
112
|
+
)
|
|
113
|
+
continue;
|
|
114
|
+
occurrences.push({
|
|
115
|
+
originalStartLocal: slot,
|
|
116
|
+
start_at: startInstant.toString(),
|
|
117
|
+
end_at: endInstant.toString(),
|
|
118
|
+
is_all_day: anchor.allDay,
|
|
119
|
+
isException: Boolean(exception),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
occurrences.sort(
|
|
123
|
+
(a, b) =>
|
|
124
|
+
Temporal.Instant.compare(a.start_at, b.start_at) ||
|
|
125
|
+
a.originalStartLocal.localeCompare(b.originalStartLocal)
|
|
126
|
+
);
|
|
127
|
+
return {
|
|
128
|
+
occurrences: occurrences.slice(0, limit),
|
|
129
|
+
truncated: occurrences.length > limit,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
import type { CalendarRecurrenceAnchor } from '@tuturuuu/types/primitives/calendar-recurrence';
|
|
3
|
+
export function recurrenceLocalParts(value: string) {
|
|
4
|
+
const date = Temporal.PlainDateTime.from(value);
|
|
5
|
+
return { day: date.day, month: date.month, dayOfWeek: date.dayOfWeek };
|
|
6
|
+
}
|
|
7
|
+
export function recurrenceInitialTimes(timeZone: string, now: Date) {
|
|
8
|
+
const start = Temporal.Instant.from(now.toISOString())
|
|
9
|
+
.toZonedDateTimeISO(timeZone)
|
|
10
|
+
.toPlainDateTime()
|
|
11
|
+
.with({ second: 0, millisecond: 0 });
|
|
12
|
+
return {
|
|
13
|
+
startLocal: start.toString({ smallestUnit: 'minute' }),
|
|
14
|
+
endLocal: start.add({ hours: 1 }).toString({ smallestUnit: 'minute' }),
|
|
15
|
+
day: start.day,
|
|
16
|
+
dayOfWeek: start.dayOfWeek,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function recurrenceInstantLocal(instant: string, timeZone: string) {
|
|
20
|
+
return Temporal.Instant.from(instant)
|
|
21
|
+
.toZonedDateTimeISO(timeZone)
|
|
22
|
+
.toPlainDateTime()
|
|
23
|
+
.toString({ smallestUnit: 'minute' });
|
|
24
|
+
}
|
|
25
|
+
export function recurrenceLocalAnchor(
|
|
26
|
+
start: string,
|
|
27
|
+
end: string,
|
|
28
|
+
allDay: boolean
|
|
29
|
+
): CalendarRecurrenceAnchor {
|
|
30
|
+
return {
|
|
31
|
+
startLocal: Temporal.PlainDateTime.from(start).toString({
|
|
32
|
+
smallestUnit: 'second',
|
|
33
|
+
}),
|
|
34
|
+
endLocal: Temporal.PlainDateTime.from(end).toString({
|
|
35
|
+
smallestUnit: 'second',
|
|
36
|
+
}),
|
|
37
|
+
allDay,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function recurrenceMidnightRange(startLocal: string, endLocal: string) {
|
|
41
|
+
const start = Temporal.PlainDateTime.from(startLocal).toPlainDate();
|
|
42
|
+
let end = Temporal.PlainDateTime.from(endLocal).toPlainDate();
|
|
43
|
+
if (Temporal.PlainDate.compare(end, start) <= 0) end = start.add({ days: 1 });
|
|
44
|
+
return { startLocal: `${start}T00:00`, endLocal: `${end}T00:00` };
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
import type { CalendarRecurrenceRule } from '@tuturuuu/types/primitives/calendar-recurrence';
|
|
3
|
+
export const CALENDAR_WEEKDAYS = [
|
|
4
|
+
'MO',
|
|
5
|
+
'TU',
|
|
6
|
+
'WE',
|
|
7
|
+
'TH',
|
|
8
|
+
'FR',
|
|
9
|
+
'SA',
|
|
10
|
+
'SU',
|
|
11
|
+
] as const;
|
|
12
|
+
export function matchesCalendarRecurrenceDate(
|
|
13
|
+
date: Temporal.PlainDate,
|
|
14
|
+
first: Temporal.PlainDate,
|
|
15
|
+
rule: CalendarRecurrenceRule
|
|
16
|
+
) {
|
|
17
|
+
const days = first.until(date, { largestUnit: 'days' }).days;
|
|
18
|
+
if (rule.frequency === 'daily') return days % rule.interval === 0;
|
|
19
|
+
const day = CALENDAR_WEEKDAYS[date.dayOfWeek - 1]!;
|
|
20
|
+
if (rule.frequency === 'weekly') {
|
|
21
|
+
const weekStart = CALENDAR_WEEKDAYS.indexOf(rule.weekStartsOn ?? 'MO') + 1;
|
|
22
|
+
const leading = (first.dayOfWeek - weekStart + 7) % 7;
|
|
23
|
+
return (
|
|
24
|
+
Math.floor((days + leading) / 7) % rule.interval === 0 &&
|
|
25
|
+
rule.weekdays!.includes(day)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const months = (date.year - first.year) * 12 + date.month - first.month;
|
|
29
|
+
if (
|
|
30
|
+
rule.frequency === 'monthly'
|
|
31
|
+
? months % rule.interval !== 0
|
|
32
|
+
: (date.year - first.year) % rule.interval !== 0 ||
|
|
33
|
+
date.month !== rule.month
|
|
34
|
+
)
|
|
35
|
+
return false;
|
|
36
|
+
if (rule.monthDay !== undefined)
|
|
37
|
+
return (
|
|
38
|
+
date.day ===
|
|
39
|
+
(rule.monthDayOverflow === 'last-day'
|
|
40
|
+
? Math.min(rule.monthDay, date.daysInMonth)
|
|
41
|
+
: rule.monthDay)
|
|
42
|
+
);
|
|
43
|
+
// Graph relative patterns select the earliest matching ordinal weekday.
|
|
44
|
+
const candidates = rule.weekdays!.map((weekday) => {
|
|
45
|
+
const index = CALENDAR_WEEKDAYS.indexOf(weekday) + 1;
|
|
46
|
+
if (rule.weekIndex === -1) {
|
|
47
|
+
const last = date.with({ day: date.daysInMonth });
|
|
48
|
+
return last.day - ((last.dayOfWeek - index + 7) % 7);
|
|
49
|
+
}
|
|
50
|
+
const start = date.with({ day: 1 });
|
|
51
|
+
return 1 + ((index - start.dayOfWeek + 7) % 7) + (rule.weekIndex! - 1) * 7;
|
|
52
|
+
});
|
|
53
|
+
return date.day === Math.min(...candidates);
|
|
54
|
+
}
|