@tuturuuu/utils 0.34.0 → 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.
@@ -0,0 +1,274 @@
1
+ import { Temporal } from '@js-temporal/polyfill';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ assessTeacherHoursSlot,
5
+ type HoursSlot,
6
+ resetTeacherHoursOverride,
7
+ resolveTeacherHours,
8
+ type StaffHours,
9
+ validateHoursFrame,
10
+ validateHoursWeek,
11
+ validateHoursWriteReceipt,
12
+ } from '../tutoring-teacher-hours';
13
+
14
+ const defaults: StaffHours = {
15
+ revision: '900719925474099312345',
16
+ frame: { timeZone: 'Asia/Ho_Chi_Minh', confirmed: true },
17
+ week: { 1: [{ start: '09:00', end: '17:00' }], 2: [] },
18
+ };
19
+ const monday: HoursSlot = {
20
+ date: '2026-10-05',
21
+ start: '09:00',
22
+ endDate: '2026-10-05',
23
+ end: '10:00',
24
+ };
25
+ const decide = (slot: HoursSlot, hours = defaults) =>
26
+ assessTeacherHoursSlot(hours, null, slot).state;
27
+
28
+ describe('teacher-hours authority and per-day inheritance', () => {
29
+ it('keeps teacher availability unknown without confirmed staff hours', () => {
30
+ expect(
31
+ resolveTeacherHours(null, null).every((day) => day.state === 'unknown')
32
+ ).toBe(true);
33
+ expect(assessTeacherHoursSlot(null, null, monday)).toEqual({
34
+ state: 'unknown',
35
+ });
36
+ });
37
+
38
+ it('distinguishes missing staff day from explicitly closed day', () => {
39
+ const week = resolveTeacherHours(defaults, null);
40
+ expect(week[0]).toMatchObject({ state: 'hours', source: 'default' });
41
+ expect(week[1]).toMatchObject({ state: 'closed', source: 'default' });
42
+ expect(week[2]).toMatchObject({ state: 'unknown', source: 'unknown' });
43
+ });
44
+
45
+ it('replaces, never unions, one overridden day and inherits absent weekdays', () => {
46
+ const week = resolveTeacherHours(defaults, {
47
+ revision: 'override:1',
48
+ week: { 1: [{ start: '18:00', end: '20:00' }] },
49
+ });
50
+ expect(week[0]?.intervals).toEqual([{ start: '18:00', end: '20:00' }]);
51
+ expect(week[0]?.source).toBe('override');
52
+ expect(week[1]?.state).toBe('closed');
53
+ });
54
+
55
+ it('explicit empty override closes the day; removing it inherits latest default', () => {
56
+ expect(
57
+ resolveTeacherHours(defaults, { revision: 'a', week: { 1: [] } })[0]
58
+ ?.state
59
+ ).toBe('closed');
60
+ const reset = resetTeacherHoursOverride('a', 'b');
61
+ expect(reset).toEqual({ revision: 'b', week: {} });
62
+ expect(
63
+ resolveTeacherHours({ ...defaults, week: { 1: [] } }, reset)[0]?.state
64
+ ).toBe('closed');
65
+ expect(resolveTeacherHours(defaults, reset)[0]?.state).toBe('hours');
66
+ });
67
+
68
+ it('cannot use custom overrides without a confirmed staff timezone', () => {
69
+ expect(
70
+ resolveTeacherHours(null, {
71
+ revision: 'a',
72
+ week: { 1: [{ start: '09:00', end: '17:00' }] },
73
+ })[0]?.state
74
+ ).toBe('unknown');
75
+ });
76
+
77
+ it('returns defensive interval copies', () => {
78
+ const week = resolveTeacherHours(defaults, null);
79
+ expect(week[0]?.intervals[0]).not.toBe(defaults.week[1]?.[0]);
80
+ });
81
+ });
82
+
83
+ describe('lossless receipt and reset contract', () => {
84
+ it('retains opaque revisions above JS safe precision and tombstone after reset', () => {
85
+ const before = '900719925474099312345';
86
+ const after = '900719925474099312346';
87
+ expect(Number(before)).toBe(Number(after));
88
+ const reset = resetTeacherHoursOverride(before, after);
89
+ expect(reset.revision).toBe(after);
90
+ expect(reset.week).toEqual({});
91
+ expect(resetTeacherHoursOverride(null, before).revision).toBe(before);
92
+ });
93
+
94
+ it('rejects reused same-content receipt revisions and malformed revisions', () => {
95
+ expect(() => validateHoursWriteReceipt('a', 'a')).toThrow();
96
+ expect(() => validateHoursWriteReceipt('a', '')).toThrow();
97
+ expect(() => validateHoursWriteReceipt(' ', 'b')).toThrow();
98
+ expect(() => validateHoursWriteReceipt(null, 'b')).not.toThrow();
99
+ // No pure helper claims to enforce storage CAS: stale/reset ABA needs atomic DB proof.
100
+ });
101
+ });
102
+
103
+ describe('strict frame and weekly window validation', () => {
104
+ it.each(['auto', '', '+07:00', 'Not/AZone'])(
105
+ 'rejects nonconfirmed IANA frame %s',
106
+ (timeZone) => {
107
+ expect(() => validateHoursFrame({ timeZone, confirmed: true })).toThrow();
108
+ }
109
+ );
110
+ it('rejects unconfirmed valid timezone', () => {
111
+ expect(() =>
112
+ validateHoursFrame({ timeZone: 'UTC', confirmed: false } as never)
113
+ ).toThrow();
114
+ });
115
+ it.each([
116
+ { 0: [] },
117
+ { 8: [] },
118
+ { 1: [{ start: '9:00', end: '10:00' }] },
119
+ { 1: [{ start: '24:00', end: '24:00' }] },
120
+ { 1: [{ start: '10:00', end: '09:00' }] },
121
+ {
122
+ 1: [
123
+ { start: '09:00', end: '11:00' },
124
+ { start: '10:00', end: '12:00' },
125
+ ],
126
+ },
127
+ {
128
+ 1: [
129
+ { start: '12:00', end: '13:00' },
130
+ { start: '09:00', end: '10:00' },
131
+ ],
132
+ },
133
+ ])('rejects malformed/overlapping/unsorted week %j', (week) => {
134
+ expect(() => validateHoursWeek(week)).toThrow();
135
+ });
136
+ it('allows half-open adjacency and explicit midnight end', () => {
137
+ expect(() =>
138
+ validateHoursWeek({
139
+ 1: [
140
+ { start: '00:00', end: '12:00' },
141
+ { start: '12:00', end: '24:00' },
142
+ ],
143
+ })
144
+ ).not.toThrow();
145
+ });
146
+ });
147
+
148
+ describe('candidate coverage and actual Temporal boundary', () => {
149
+ it('allows exact boundaries and rejects outside hours', () => {
150
+ expect(decide({ ...monday, end: '17:00' })).toBe('allowed');
151
+ expect(decide({ ...monday, start: '08:59' })).toBe('closed');
152
+ expect(decide({ ...monday, end: '17:01' })).toBe('closed');
153
+ expect(
154
+ decide({ ...monday, date: '2026-10-07', endDate: '2026-10-07' })
155
+ ).toBe('unknown');
156
+ });
157
+ it('covers adjacent intervals continuously without bridging a gap', () => {
158
+ const week = {
159
+ 1: [
160
+ { start: '09:00', end: '10:00' },
161
+ { start: '10:00', end: '11:00' },
162
+ ],
163
+ };
164
+ expect(decide({ ...monday, end: '11:00' }, { ...defaults, week })).toBe(
165
+ 'allowed'
166
+ );
167
+ week[1][1] = { start: '10:01', end: '11:00' };
168
+ expect(decide({ ...monday, end: '11:00' }, { ...defaults, week })).toBe(
169
+ 'closed'
170
+ );
171
+ });
172
+ it('requires explicit coverage on both days for overnight and Sunday wrap', () => {
173
+ const slot = {
174
+ date: '2026-10-11',
175
+ start: '23:00',
176
+ endDate: '2026-10-12',
177
+ end: '01:00',
178
+ };
179
+ const hours = {
180
+ ...defaults,
181
+ week: {
182
+ 7: [{ start: '22:00', end: '24:00' }],
183
+ 1: [{ start: '00:00', end: '02:00' }],
184
+ },
185
+ };
186
+ expect(decide(slot, hours)).toBe('allowed');
187
+ expect(decide(slot, { ...hours, week: { 7: hours.week[7] } })).toBe(
188
+ 'unknown'
189
+ );
190
+ expect(decide(slot, { ...hours, week: { ...hours.week, 1: [] } })).toBe(
191
+ 'closed'
192
+ );
193
+ expect(
194
+ decide(
195
+ { ...slot, end: '00:00' },
196
+ { ...hours, week: { 7: hours.week[7] } }
197
+ )
198
+ ).toBe('allowed');
199
+ });
200
+ it('rejects zero/negative/unbounded slots and invalid civil dates', () => {
201
+ for (const slot of [
202
+ { ...monday, end: '09:00' },
203
+ { ...monday, end: '08:00' },
204
+ { ...monday, endDate: '2026-10-13' },
205
+ { ...monday, date: '2026-02-30' },
206
+ { ...monday, start: '9:00' },
207
+ ])
208
+ expect(decide(slot)).toBe('invalid');
209
+ });
210
+ it('proves compatible Temporal conversion would move a nonexistent clock time', () => {
211
+ const local = Temporal.PlainDateTime.from('2026-03-08T02:30');
212
+ expect(local.toZonedDateTime('America/New_York').hour).toBe(3);
213
+ const hours = {
214
+ ...defaults,
215
+ frame: { timeZone: 'America/New_York', confirmed: true as const },
216
+ week: { 7: [{ start: '00:00', end: '24:00' }] },
217
+ };
218
+ expect(
219
+ decide(
220
+ {
221
+ date: '2026-03-08',
222
+ start: '02:30',
223
+ endDate: '2026-03-08',
224
+ end: '04:00',
225
+ },
226
+ hours
227
+ )
228
+ ).toBe('invalid');
229
+ expect(
230
+ decide(
231
+ {
232
+ date: '2026-11-01',
233
+ start: '01:30',
234
+ endDate: '2026-11-01',
235
+ end: '03:00',
236
+ },
237
+ hours
238
+ )
239
+ ).toBe('invalid');
240
+ expect(
241
+ decide(
242
+ {
243
+ date: '2026-03-08',
244
+ start: '01:00',
245
+ endDate: '2026-03-08',
246
+ end: '04:00',
247
+ },
248
+ hours
249
+ )
250
+ ).toBe('invalid');
251
+ expect(
252
+ decide(
253
+ {
254
+ date: '2026-11-01',
255
+ start: '00:30',
256
+ endDate: '2026-11-01',
257
+ end: '03:00',
258
+ },
259
+ hours
260
+ )
261
+ ).toBe('invalid');
262
+ expect(
263
+ decide(
264
+ {
265
+ date: '2026-03-09',
266
+ start: '09:00',
267
+ endDate: '2026-03-09',
268
+ end: '10:00',
269
+ },
270
+ { ...hours, week: defaults.week }
271
+ )
272
+ ).toBe('allowed');
273
+ });
274
+ });
@@ -0,0 +1,49 @@
1
+ /** Internal-only transport marker; never written back to a provider. */
2
+ const marker = '__tuturuuuProviderReadonlyRecurrence';
3
+ export type ProviderReadonlyRecurrence = {
4
+ state: 'unsupported';
5
+ master_id: string;
6
+ provider: 'google' | 'microsoft';
7
+ };
8
+ export function markProviderRecurrenceReadonly<T extends object>(
9
+ event: T,
10
+ provider: ProviderReadonlyRecurrence['provider'],
11
+ masterId: string
12
+ ): T {
13
+ return {
14
+ ...event,
15
+ [marker]: { state: 'unsupported', master_id: masterId, provider },
16
+ };
17
+ }
18
+ export function providerReadonlyProjection(event: unknown): {
19
+ locked?: true;
20
+ scheduling_metadata?: { provider_recurrence: ProviderReadonlyRecurrence };
21
+ } {
22
+ if (!event || typeof event !== 'object') return {};
23
+ const value = (event as Record<string, unknown>)[marker];
24
+ if (!value || typeof value !== 'object') return {};
25
+ const entry = value as Partial<ProviderReadonlyRecurrence>;
26
+ if (
27
+ entry.state !== 'unsupported' ||
28
+ typeof entry.master_id !== 'string' ||
29
+ !entry.master_id ||
30
+ !['google', 'microsoft'].includes(entry.provider ?? '')
31
+ )
32
+ return {};
33
+ return {
34
+ locked: true,
35
+ scheduling_metadata: {
36
+ provider_recurrence: entry as ProviderReadonlyRecurrence,
37
+ },
38
+ };
39
+ }
40
+ export function isProviderRecurrenceReadonly(event: unknown): boolean {
41
+ if (!event || typeof event !== 'object') return false;
42
+ const metadata = (event as { scheduling_metadata?: unknown })
43
+ .scheduling_metadata;
44
+ if (!metadata || typeof metadata !== 'object') return false;
45
+ return (
46
+ (metadata as { provider_recurrence?: { state?: unknown } })
47
+ .provider_recurrence?.state === 'unsupported'
48
+ );
49
+ }
@@ -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
+ }