@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.
- package/CHANGELOG.md +21 -0
- package/package.json +4 -3
- 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__/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/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/deployment-vault-permissions.tsx +26 -0
- package/src/desktop-signing-profiles.ts +20 -0
- package/src/next-config.ts +8 -1
- package/src/permissions.tsx +2 -10
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
import type {
|
|
3
|
+
CalendarRecurrenceAnchor,
|
|
4
|
+
CalendarRecurrenceRule,
|
|
5
|
+
CalendarWeekday,
|
|
6
|
+
} from '@tuturuuu/types/primitives/calendar-recurrence';
|
|
7
|
+
import { CALENDAR_WEEKDAYS } from './calendar-recurrence-pattern';
|
|
8
|
+
import {
|
|
9
|
+
CalendarRecurrenceRuleSchema,
|
|
10
|
+
validateCalendarRecurrence,
|
|
11
|
+
} from './calendar-recurrence-validation';
|
|
12
|
+
|
|
13
|
+
const graphDays = [
|
|
14
|
+
'monday',
|
|
15
|
+
'tuesday',
|
|
16
|
+
'wednesday',
|
|
17
|
+
'thursday',
|
|
18
|
+
'friday',
|
|
19
|
+
'saturday',
|
|
20
|
+
'sunday',
|
|
21
|
+
] as const;
|
|
22
|
+
const indexes = ['first', 'second', 'third', 'fourth', 'last'] as const;
|
|
23
|
+
export class UnsupportedCalendarRecurrenceError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'UnsupportedCalendarRecurrenceError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export interface GraphCalendarRecurrence {
|
|
30
|
+
pattern: {
|
|
31
|
+
type: string;
|
|
32
|
+
interval: number;
|
|
33
|
+
dayOfMonth?: number;
|
|
34
|
+
daysOfWeek?: string[];
|
|
35
|
+
firstDayOfWeek?: string;
|
|
36
|
+
index?: string;
|
|
37
|
+
month?: number;
|
|
38
|
+
};
|
|
39
|
+
range: {
|
|
40
|
+
type: string;
|
|
41
|
+
startDate: string;
|
|
42
|
+
recurrenceTimeZone?: string;
|
|
43
|
+
endDate?: string;
|
|
44
|
+
numberOfOccurrences?: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function graphDay(day: CalendarWeekday) {
|
|
48
|
+
return graphDays[CALENDAR_WEEKDAYS.indexOf(day)]!;
|
|
49
|
+
}
|
|
50
|
+
function canonicalDay(day: string): CalendarWeekday {
|
|
51
|
+
const index = graphDays.indexOf(day as (typeof graphDays)[number]);
|
|
52
|
+
if (index < 0)
|
|
53
|
+
throw new UnsupportedCalendarRecurrenceError('Unknown provider weekday');
|
|
54
|
+
return CALENDAR_WEEKDAYS[index]!;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toGraphCalendarRecurrence(
|
|
58
|
+
rule: CalendarRecurrenceRule,
|
|
59
|
+
anchor: CalendarRecurrenceAnchor
|
|
60
|
+
): GraphCalendarRecurrence {
|
|
61
|
+
validateCalendarRecurrence(rule, anchor);
|
|
62
|
+
if (
|
|
63
|
+
rule.monthDay &&
|
|
64
|
+
rule.monthDay > 28 &&
|
|
65
|
+
rule.monthDayOverflow !== 'last-day'
|
|
66
|
+
)
|
|
67
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
68
|
+
'Outlook clamps missing month days; choose last-day overflow explicitly'
|
|
69
|
+
);
|
|
70
|
+
const relative = rule.weekIndex !== undefined;
|
|
71
|
+
const pattern: GraphCalendarRecurrence['pattern'] = {
|
|
72
|
+
type:
|
|
73
|
+
rule.frequency === 'monthly'
|
|
74
|
+
? relative
|
|
75
|
+
? 'relativeMonthly'
|
|
76
|
+
: 'absoluteMonthly'
|
|
77
|
+
: rule.frequency === 'yearly'
|
|
78
|
+
? relative
|
|
79
|
+
? 'relativeYearly'
|
|
80
|
+
: 'absoluteYearly'
|
|
81
|
+
: rule.frequency,
|
|
82
|
+
interval: rule.interval,
|
|
83
|
+
...(rule.monthDay === undefined ? {} : { dayOfMonth: rule.monthDay }),
|
|
84
|
+
...(rule.month === undefined ? {} : { month: rule.month }),
|
|
85
|
+
...(rule.weekdays ? { daysOfWeek: rule.weekdays.map(graphDay) } : {}),
|
|
86
|
+
...(rule.frequency === 'weekly'
|
|
87
|
+
? { firstDayOfWeek: graphDay(rule.weekStartsOn ?? 'MO') }
|
|
88
|
+
: {}),
|
|
89
|
+
...(relative
|
|
90
|
+
? { index: indexes[rule.weekIndex === -1 ? 4 : rule.weekIndex! - 1] }
|
|
91
|
+
: {}),
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
pattern,
|
|
95
|
+
range: {
|
|
96
|
+
type:
|
|
97
|
+
rule.end.type === 'count'
|
|
98
|
+
? 'numbered'
|
|
99
|
+
: rule.end.type === 'until'
|
|
100
|
+
? 'endDate'
|
|
101
|
+
: 'noEnd',
|
|
102
|
+
startDate: anchor.startLocal.slice(0, 10),
|
|
103
|
+
recurrenceTimeZone: rule.timeZone,
|
|
104
|
+
...(rule.end.type === 'count'
|
|
105
|
+
? { numberOfOccurrences: rule.end.count }
|
|
106
|
+
: {}),
|
|
107
|
+
...(rule.end.type === 'until' ? { endDate: rule.end.date } : {}),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function fromGraphCalendarRecurrence(
|
|
113
|
+
value: GraphCalendarRecurrence,
|
|
114
|
+
timeZone: string
|
|
115
|
+
): CalendarRecurrenceRule {
|
|
116
|
+
const { pattern, range } = value;
|
|
117
|
+
const frequencies: Record<string, CalendarRecurrenceRule['frequency']> = {
|
|
118
|
+
daily: 'daily',
|
|
119
|
+
weekly: 'weekly',
|
|
120
|
+
absoluteMonthly: 'monthly',
|
|
121
|
+
relativeMonthly: 'monthly',
|
|
122
|
+
absoluteYearly: 'yearly',
|
|
123
|
+
relativeYearly: 'yearly',
|
|
124
|
+
};
|
|
125
|
+
const frequency = frequencies[pattern.type];
|
|
126
|
+
if (!frequency)
|
|
127
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
128
|
+
'Unknown provider recurrence pattern'
|
|
129
|
+
);
|
|
130
|
+
const relative = pattern.type.startsWith('relative');
|
|
131
|
+
const index = indexes.indexOf(
|
|
132
|
+
(pattern.index ?? 'first') as (typeof indexes)[number]
|
|
133
|
+
);
|
|
134
|
+
if (relative && index < 0)
|
|
135
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
136
|
+
'Unknown provider recurrence index'
|
|
137
|
+
);
|
|
138
|
+
if (!['numbered', 'endDate', 'noEnd'].includes(range.type))
|
|
139
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
140
|
+
'Unknown provider recurrence range'
|
|
141
|
+
);
|
|
142
|
+
return CalendarRecurrenceRuleSchema.parse({
|
|
143
|
+
version: 1,
|
|
144
|
+
frequency,
|
|
145
|
+
interval: pattern.interval,
|
|
146
|
+
timeZone: range.recurrenceTimeZone || timeZone,
|
|
147
|
+
...(frequency === 'weekly' || relative
|
|
148
|
+
? { weekdays: pattern.daysOfWeek?.map(canonicalDay) }
|
|
149
|
+
: {}),
|
|
150
|
+
...(frequency === 'weekly'
|
|
151
|
+
? { weekStartsOn: canonicalDay(pattern.firstDayOfWeek ?? 'sunday') }
|
|
152
|
+
: {}),
|
|
153
|
+
...(relative ? { weekIndex: index === 4 ? -1 : index + 1 } : {}),
|
|
154
|
+
...(!relative && (frequency === 'monthly' || frequency === 'yearly')
|
|
155
|
+
? {
|
|
156
|
+
monthDay: pattern.dayOfMonth,
|
|
157
|
+
...(pattern.dayOfMonth && pattern.dayOfMonth > 28
|
|
158
|
+
? { monthDayOverflow: 'last-day' }
|
|
159
|
+
: {}),
|
|
160
|
+
}
|
|
161
|
+
: {}),
|
|
162
|
+
...(frequency === 'yearly' ? { month: pattern.month } : {}),
|
|
163
|
+
end:
|
|
164
|
+
range.type === 'numbered'
|
|
165
|
+
? { type: 'count', count: range.numberOfOccurrences }
|
|
166
|
+
: range.type === 'endDate'
|
|
167
|
+
? { type: 'until', date: range.endDate }
|
|
168
|
+
: { type: 'never' },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function toGoogleCalendarRecurrence(
|
|
173
|
+
rule: CalendarRecurrenceRule,
|
|
174
|
+
anchor: CalendarRecurrenceAnchor
|
|
175
|
+
): string[] {
|
|
176
|
+
validateCalendarRecurrence(rule, anchor);
|
|
177
|
+
const parts = [
|
|
178
|
+
`FREQ=${rule.frequency.toUpperCase()}`,
|
|
179
|
+
`INTERVAL=${rule.interval}`,
|
|
180
|
+
];
|
|
181
|
+
if (rule.frequency === 'weekly')
|
|
182
|
+
parts.push(`WKST=${rule.weekStartsOn ?? 'MO'}`);
|
|
183
|
+
if (rule.weekdays) {
|
|
184
|
+
parts.push(
|
|
185
|
+
`BYDAY=${rule.weekdays.map((day) => `${rule.weekIndex ?? ''}${day}`).join(',')}`
|
|
186
|
+
);
|
|
187
|
+
if (rule.weekIndex !== undefined && rule.weekdays.length > 1)
|
|
188
|
+
parts.push('BYSETPOS=1');
|
|
189
|
+
}
|
|
190
|
+
if (rule.monthDay !== undefined) {
|
|
191
|
+
if (rule.monthDayOverflow === 'last-day' && rule.monthDay > 28) {
|
|
192
|
+
parts.push(
|
|
193
|
+
`BYMONTHDAY=${Array.from({ length: rule.monthDay - 27 }, (_, index) => index + 28).join(',')}`,
|
|
194
|
+
'BYSETPOS=-1'
|
|
195
|
+
);
|
|
196
|
+
} else parts.push(`BYMONTHDAY=${rule.monthDay}`);
|
|
197
|
+
}
|
|
198
|
+
if (rule.month !== undefined) parts.push(`BYMONTH=${rule.month}`);
|
|
199
|
+
if (rule.end.type === 'count') parts.push(`COUNT=${rule.end.count}`);
|
|
200
|
+
if (rule.end.type === 'until') {
|
|
201
|
+
const until = anchor.allDay
|
|
202
|
+
? rule.end.date.replaceAll('-', '')
|
|
203
|
+
: Temporal.PlainDate.from(rule.end.date)
|
|
204
|
+
.toPlainDateTime('23:59:59')
|
|
205
|
+
.toZonedDateTime(rule.timeZone)
|
|
206
|
+
.toInstant()
|
|
207
|
+
.toString()
|
|
208
|
+
.replaceAll('-', '')
|
|
209
|
+
.replaceAll(':', '');
|
|
210
|
+
parts.push(`UNTIL=${until}`);
|
|
211
|
+
}
|
|
212
|
+
return [`RRULE:${parts.join(';')}`];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Richer Google RRULE/RDATE/EXDATE data must be retained read-only, never dropped. */
|
|
216
|
+
export function fromGoogleCalendarRecurrence(
|
|
217
|
+
value: string[],
|
|
218
|
+
anchor: CalendarRecurrenceAnchor,
|
|
219
|
+
timeZone: string
|
|
220
|
+
): CalendarRecurrenceRule {
|
|
221
|
+
if (value.length !== 1 || !value[0]?.startsWith('RRULE:'))
|
|
222
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
223
|
+
'Only one common RRULE is editable; retain original provider recurrence'
|
|
224
|
+
);
|
|
225
|
+
const entries = value[0]
|
|
226
|
+
.slice(6)
|
|
227
|
+
.split(';')
|
|
228
|
+
.map((part) => part.split('='));
|
|
229
|
+
if (
|
|
230
|
+
entries.some((parts) => parts.length !== 2) ||
|
|
231
|
+
new Set(entries.map(([key]) => key)).size !== entries.length
|
|
232
|
+
)
|
|
233
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
234
|
+
'Malformed or duplicate RRULE fields'
|
|
235
|
+
);
|
|
236
|
+
const fields = Object.fromEntries(entries) as Record<string, string>;
|
|
237
|
+
if (
|
|
238
|
+
Object.keys(fields).some(
|
|
239
|
+
(key) =>
|
|
240
|
+
![
|
|
241
|
+
'FREQ',
|
|
242
|
+
'INTERVAL',
|
|
243
|
+
'WKST',
|
|
244
|
+
'BYDAY',
|
|
245
|
+
'BYSETPOS',
|
|
246
|
+
'BYMONTHDAY',
|
|
247
|
+
'BYMONTH',
|
|
248
|
+
'COUNT',
|
|
249
|
+
'UNTIL',
|
|
250
|
+
].includes(key)
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
throw new UnsupportedCalendarRecurrenceError('Unsupported RRULE selectors');
|
|
254
|
+
if (fields.COUNT && fields.UNTIL)
|
|
255
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
256
|
+
'COUNT and UNTIL are mutually exclusive'
|
|
257
|
+
);
|
|
258
|
+
const frequency = fields.FREQ?.toLowerCase();
|
|
259
|
+
const byday = fields.BYDAY?.split(',').map((value) => {
|
|
260
|
+
const match = /^(-1|[1-4])?(MO|TU|WE|TH|FR|SA|SU)$/.exec(value);
|
|
261
|
+
if (!match)
|
|
262
|
+
throw new UnsupportedCalendarRecurrenceError('Unsupported BYDAY');
|
|
263
|
+
return {
|
|
264
|
+
day: match[2] as CalendarWeekday,
|
|
265
|
+
index: match[1] ? Number(match[1]) : undefined,
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
const index = byday?.[0]?.index;
|
|
269
|
+
if (index !== undefined && frequency !== 'monthly' && frequency !== 'yearly')
|
|
270
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
271
|
+
'Ordinal weekdays require monthly or yearly recurrence'
|
|
272
|
+
);
|
|
273
|
+
const monthDays = fields.BYMONTHDAY?.split(',').map(Number);
|
|
274
|
+
const clampsMonthDay =
|
|
275
|
+
!byday &&
|
|
276
|
+
fields.BYSETPOS === '-1' &&
|
|
277
|
+
Boolean(
|
|
278
|
+
monthDays &&
|
|
279
|
+
monthDays.length > 1 &&
|
|
280
|
+
monthDays.every((day, i) => day === 28 + i) &&
|
|
281
|
+
monthDays.at(-1)! <= 31
|
|
282
|
+
);
|
|
283
|
+
if (
|
|
284
|
+
byday?.some((day) => day.index !== index) ||
|
|
285
|
+
(fields.BYSETPOS &&
|
|
286
|
+
!clampsMonthDay &&
|
|
287
|
+
(fields.BYSETPOS !== '1' || index === undefined)) ||
|
|
288
|
+
(index !== undefined && byday!.length > 1 && fields.BYSETPOS !== '1')
|
|
289
|
+
)
|
|
290
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
291
|
+
'Relative RRULE is not representable by Graph'
|
|
292
|
+
);
|
|
293
|
+
const number = (value: string | undefined, fallback?: number) => {
|
|
294
|
+
if (value === undefined) return fallback;
|
|
295
|
+
if (!/^\d+$/.test(value))
|
|
296
|
+
throw new UnsupportedCalendarRecurrenceError(
|
|
297
|
+
'Invalid numeric RRULE field'
|
|
298
|
+
);
|
|
299
|
+
return Number(value);
|
|
300
|
+
};
|
|
301
|
+
let end: CalendarRecurrenceRule['end'] = { type: 'never' };
|
|
302
|
+
if (fields.COUNT) end = { type: 'count', count: number(fields.COUNT)! };
|
|
303
|
+
if (fields.UNTIL) {
|
|
304
|
+
let date: Temporal.PlainDate;
|
|
305
|
+
if (anchor.allDay && /^\d{8}$/.test(fields.UNTIL))
|
|
306
|
+
date = Temporal.PlainDate.from(
|
|
307
|
+
`${fields.UNTIL.slice(0, 4)}-${fields.UNTIL.slice(4, 6)}-${fields.UNTIL.slice(6)}`
|
|
308
|
+
);
|
|
309
|
+
else if (!anchor.allDay && /^\d{8}T\d{6}Z$/.test(fields.UNTIL)) {
|
|
310
|
+
const raw = fields.UNTIL;
|
|
311
|
+
const cutoff = Temporal.Instant.from(
|
|
312
|
+
`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T${raw.slice(9, 11)}:${raw.slice(11, 13)}:${raw.slice(13, 15)}Z`
|
|
313
|
+
);
|
|
314
|
+
date = cutoff.toZonedDateTimeISO(timeZone).toPlainDate();
|
|
315
|
+
const slot = date
|
|
316
|
+
.toPlainDateTime(
|
|
317
|
+
Temporal.PlainDateTime.from(anchor.startLocal).toPlainTime()
|
|
318
|
+
)
|
|
319
|
+
.toZonedDateTime(timeZone)
|
|
320
|
+
.toInstant();
|
|
321
|
+
if (Temporal.Instant.compare(slot, cutoff) > 0)
|
|
322
|
+
date = date.subtract({ days: 1 });
|
|
323
|
+
} else
|
|
324
|
+
throw new UnsupportedCalendarRecurrenceError('Invalid UNTIL date type');
|
|
325
|
+
end = { type: 'until', date: date.toString() };
|
|
326
|
+
}
|
|
327
|
+
const anchorDate = Temporal.PlainDateTime.from(
|
|
328
|
+
anchor.startLocal
|
|
329
|
+
).toPlainDate();
|
|
330
|
+
const rule = CalendarRecurrenceRuleSchema.parse({
|
|
331
|
+
version: 1,
|
|
332
|
+
frequency,
|
|
333
|
+
interval: number(fields.INTERVAL, 1),
|
|
334
|
+
timeZone,
|
|
335
|
+
...(frequency === 'weekly'
|
|
336
|
+
? {
|
|
337
|
+
weekdays: byday?.map((day) => day.day) ?? [
|
|
338
|
+
CALENDAR_WEEKDAYS[anchorDate.dayOfWeek - 1],
|
|
339
|
+
],
|
|
340
|
+
weekStartsOn: fields.WKST ?? 'MO',
|
|
341
|
+
}
|
|
342
|
+
: byday
|
|
343
|
+
? { weekdays: byday.map((day) => day.day), weekIndex: index }
|
|
344
|
+
: {}),
|
|
345
|
+
...(fields.BYMONTHDAY
|
|
346
|
+
? clampsMonthDay
|
|
347
|
+
? { monthDay: monthDays!.at(-1), monthDayOverflow: 'last-day' }
|
|
348
|
+
: { monthDay: number(fields.BYMONTHDAY) }
|
|
349
|
+
: !byday && ['monthly', 'yearly'].includes(frequency ?? '')
|
|
350
|
+
? { monthDay: anchorDate.day }
|
|
351
|
+
: {}),
|
|
352
|
+
...(frequency === 'yearly'
|
|
353
|
+
? { month: number(fields.BYMONTH, anchorDate.month) }
|
|
354
|
+
: fields.BYMONTH
|
|
355
|
+
? { month: number(fields.BYMONTH) }
|
|
356
|
+
: {}),
|
|
357
|
+
end,
|
|
358
|
+
});
|
|
359
|
+
validateCalendarRecurrence(rule, anchor);
|
|
360
|
+
return rule;
|
|
361
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
import type {
|
|
3
|
+
CalendarRecurrenceAnchor,
|
|
4
|
+
CalendarRecurrenceRule,
|
|
5
|
+
} from '@tuturuuu/types/primitives/calendar-recurrence';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CALENDAR_WEEKDAYS,
|
|
10
|
+
matchesCalendarRecurrenceDate,
|
|
11
|
+
} from './calendar-recurrence-pattern';
|
|
12
|
+
|
|
13
|
+
const weekday = z.enum(CALENDAR_WEEKDAYS);
|
|
14
|
+
export const CalendarLocalDateSchema = z
|
|
15
|
+
.string()
|
|
16
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
17
|
+
.refine((value) => {
|
|
18
|
+
try {
|
|
19
|
+
Temporal.PlainDate.from(value, { overflow: 'reject' });
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}, 'Invalid calendar date');
|
|
25
|
+
export const CalendarLocalDateTimeSchema = z
|
|
26
|
+
.string()
|
|
27
|
+
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
|
|
28
|
+
.refine((value) => {
|
|
29
|
+
try {
|
|
30
|
+
Temporal.PlainDateTime.from(value, { overflow: 'reject' });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}, 'Invalid local date-time');
|
|
36
|
+
|
|
37
|
+
export const CalendarRecurrenceRuleSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
version: z.literal(1),
|
|
40
|
+
frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly']),
|
|
41
|
+
interval: z.number().int().min(1).max(1000),
|
|
42
|
+
timeZone: z
|
|
43
|
+
.string()
|
|
44
|
+
.max(100)
|
|
45
|
+
.refine((value) => {
|
|
46
|
+
if (/^[+-]/.test(value)) return false;
|
|
47
|
+
try {
|
|
48
|
+
Temporal.Now.zonedDateTimeISO(value);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}, 'Expected a named time zone'),
|
|
54
|
+
weekStartsOn: weekday.optional(),
|
|
55
|
+
weekdays: z
|
|
56
|
+
.array(weekday)
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(7)
|
|
59
|
+
.refine((days) => new Set(days).size === days.length)
|
|
60
|
+
.optional(),
|
|
61
|
+
monthDay: z.number().int().min(1).max(31).optional(),
|
|
62
|
+
monthDayOverflow: z.enum(['skip', 'last-day']).optional(),
|
|
63
|
+
weekIndex: z
|
|
64
|
+
.union([
|
|
65
|
+
z.literal(1),
|
|
66
|
+
z.literal(2),
|
|
67
|
+
z.literal(3),
|
|
68
|
+
z.literal(4),
|
|
69
|
+
z.literal(-1),
|
|
70
|
+
])
|
|
71
|
+
.optional(),
|
|
72
|
+
month: z.number().int().min(1).max(12).optional(),
|
|
73
|
+
end: z.discriminatedUnion('type', [
|
|
74
|
+
z.object({ type: z.literal('never') }).strict(),
|
|
75
|
+
z
|
|
76
|
+
.object({
|
|
77
|
+
type: z.literal('count'),
|
|
78
|
+
count: z.number().int().min(1).max(10000),
|
|
79
|
+
})
|
|
80
|
+
.strict(),
|
|
81
|
+
z
|
|
82
|
+
.object({ type: z.literal('until'), date: CalendarLocalDateSchema })
|
|
83
|
+
.strict(),
|
|
84
|
+
]),
|
|
85
|
+
})
|
|
86
|
+
.strict()
|
|
87
|
+
.superRefine((rule, ctx) => {
|
|
88
|
+
const invalid = (message: string) =>
|
|
89
|
+
ctx.addIssue({ code: 'custom', message });
|
|
90
|
+
if (rule.monthDayOverflow && rule.monthDay === undefined)
|
|
91
|
+
invalid('Month-day overflow requires an absolute month day');
|
|
92
|
+
if (
|
|
93
|
+
rule.frequency === 'daily' &&
|
|
94
|
+
(rule.weekdays || rule.monthDay || rule.weekIndex || rule.month)
|
|
95
|
+
)
|
|
96
|
+
invalid('Daily recurrence does not accept selectors');
|
|
97
|
+
if (
|
|
98
|
+
rule.frequency === 'weekly' &&
|
|
99
|
+
(!rule.weekdays || rule.monthDay || rule.weekIndex || rule.month)
|
|
100
|
+
)
|
|
101
|
+
invalid('Weekly recurrence requires only weekday selectors');
|
|
102
|
+
if (rule.frequency === 'monthly' || rule.frequency === 'yearly') {
|
|
103
|
+
if (
|
|
104
|
+
rule.monthDay !== undefined
|
|
105
|
+
? Boolean(rule.weekdays || rule.weekIndex)
|
|
106
|
+
: !(rule.weekdays && rule.weekIndex)
|
|
107
|
+
)
|
|
108
|
+
invalid('Choose a month day or relative weekdays and index');
|
|
109
|
+
if (rule.frequency === 'yearly' ? !rule.month : rule.month !== undefined)
|
|
110
|
+
invalid('Month is required only for yearly recurrence');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
export const CalendarRecurrenceAnchorSchema = z
|
|
114
|
+
.object({
|
|
115
|
+
startLocal: CalendarLocalDateTimeSchema,
|
|
116
|
+
endLocal: CalendarLocalDateTimeSchema,
|
|
117
|
+
allDay: z.boolean(),
|
|
118
|
+
})
|
|
119
|
+
.strict()
|
|
120
|
+
.superRefine((anchor, ctx) => {
|
|
121
|
+
if (Temporal.PlainDateTime.compare(anchor.startLocal, anchor.endLocal) >= 0)
|
|
122
|
+
ctx.addIssue({ code: 'custom', message: 'End must follow start' });
|
|
123
|
+
if (
|
|
124
|
+
anchor.allDay &&
|
|
125
|
+
![anchor.startLocal, anchor.endLocal].every((value) =>
|
|
126
|
+
value.endsWith('T00:00:00')
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
ctx.addIssue({
|
|
130
|
+
code: 'custom',
|
|
131
|
+
message: 'All-day anchors require exclusive midnight date boundaries',
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export function validateCalendarRecurrence(
|
|
136
|
+
rule: CalendarRecurrenceRule,
|
|
137
|
+
anchor: CalendarRecurrenceAnchor
|
|
138
|
+
) {
|
|
139
|
+
const validRule = CalendarRecurrenceRuleSchema.parse(rule);
|
|
140
|
+
const validAnchor = CalendarRecurrenceAnchorSchema.parse(anchor);
|
|
141
|
+
if (
|
|
142
|
+
validRule.end.type === 'until' &&
|
|
143
|
+
validRule.end.date < validAnchor.startLocal.slice(0, 10)
|
|
144
|
+
)
|
|
145
|
+
throw new RangeError('Recurrence ends before its anchor');
|
|
146
|
+
const first = Temporal.PlainDateTime.from(
|
|
147
|
+
validAnchor.startLocal
|
|
148
|
+
).toPlainDate();
|
|
149
|
+
if (!matchesCalendarRecurrenceDate(first, first, validRule))
|
|
150
|
+
throw new RangeError('Series anchor must match its recurrence pattern');
|
|
151
|
+
return { rule: validRule, anchor: validAnchor };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const CalendarRecurrenceExceptionSchema = z
|
|
155
|
+
.object({
|
|
156
|
+
originalStartLocal: CalendarLocalDateTimeSchema,
|
|
157
|
+
cancelled: z.boolean().optional(),
|
|
158
|
+
startLocal: CalendarLocalDateTimeSchema.optional(),
|
|
159
|
+
endLocal: CalendarLocalDateTimeSchema.optional(),
|
|
160
|
+
})
|
|
161
|
+
.strict()
|
|
162
|
+
.superRefine((exception, ctx) => {
|
|
163
|
+
if (Boolean(exception.startLocal) !== Boolean(exception.endLocal))
|
|
164
|
+
ctx.addIssue({
|
|
165
|
+
code: 'custom',
|
|
166
|
+
message: 'Moved occurrences require start and end',
|
|
167
|
+
});
|
|
168
|
+
if (
|
|
169
|
+
exception.startLocal &&
|
|
170
|
+
exception.endLocal &&
|
|
171
|
+
Temporal.PlainDateTime.compare(
|
|
172
|
+
exception.startLocal,
|
|
173
|
+
exception.endLocal
|
|
174
|
+
) >= 0
|
|
175
|
+
)
|
|
176
|
+
ctx.addIssue({
|
|
177
|
+
code: 'custom',
|
|
178
|
+
message: 'Moved occurrence end must follow start',
|
|
179
|
+
});
|
|
180
|
+
});
|
package/src/calendar-utils.ts
CHANGED
|
@@ -7,10 +7,17 @@ dayjs.extend(timezone);
|
|
|
7
7
|
dayjs.extend(utc);
|
|
8
8
|
|
|
9
9
|
export function isAllDayEvent(
|
|
10
|
-
event: Pick<CalendarEvent, 'start_at' | 'end_at'
|
|
10
|
+
event: Pick<CalendarEvent, 'start_at' | 'end_at'>,
|
|
11
|
+
displayTimezone?: string
|
|
11
12
|
): boolean {
|
|
12
|
-
const start =
|
|
13
|
-
|
|
13
|
+
const start =
|
|
14
|
+
displayTimezone && displayTimezone !== 'auto'
|
|
15
|
+
? dayjs(event.start_at).tz(displayTimezone)
|
|
16
|
+
: dayjs(event.start_at);
|
|
17
|
+
const end =
|
|
18
|
+
displayTimezone && displayTimezone !== 'auto'
|
|
19
|
+
? dayjs(event.end_at).tz(displayTimezone)
|
|
20
|
+
: dayjs(event.end_at);
|
|
14
21
|
|
|
15
22
|
const durationMs = end.diff(start, 'millisecond');
|
|
16
23
|
const isMultipleOf24Hours = durationMs % (24 * 60 * 60 * 1000) === 0;
|
|
@@ -27,6 +34,37 @@ export function isAllDayEvent(
|
|
|
27
34
|
);
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
/** Civil date carriers for all-day rendering, with an exclusive end date.
|
|
38
|
+
* Untagged UTC-midnight legacy imports preserve their stored YMD, matching mobile.
|
|
39
|
+
* Other rows project actual instants into the configured display timezone.
|
|
40
|
+
*/
|
|
41
|
+
export function allDayEventCivilDates(
|
|
42
|
+
event: Pick<CalendarEvent, 'start_at' | 'end_at'>,
|
|
43
|
+
displayTimezone?: string
|
|
44
|
+
) {
|
|
45
|
+
const rawStart = dayjs(event.start_at);
|
|
46
|
+
const rawEnd = dayjs(event.end_at);
|
|
47
|
+
if (!rawStart.isValid() || !rawEnd.isValid() || !rawEnd.isAfter(rawStart))
|
|
48
|
+
return null;
|
|
49
|
+
const utcStart = rawStart.utc();
|
|
50
|
+
const utcEnd = rawEnd.utc();
|
|
51
|
+
const midnight = (date: dayjs.Dayjs) =>
|
|
52
|
+
date.hour() === 0 &&
|
|
53
|
+
date.minute() === 0 &&
|
|
54
|
+
date.second() === 0 &&
|
|
55
|
+
date.millisecond() === 0;
|
|
56
|
+
const legacyDateOnly = midnight(utcStart) && midnight(utcEnd);
|
|
57
|
+
const project = (date: dayjs.Dayjs) =>
|
|
58
|
+
legacyDateOnly
|
|
59
|
+
? date.utc()
|
|
60
|
+
: displayTimezone && displayTimezone !== 'auto'
|
|
61
|
+
? date.tz(displayTimezone)
|
|
62
|
+
: date;
|
|
63
|
+
const civil = (date: dayjs.Dayjs) =>
|
|
64
|
+
new Date(date.year(), date.month(), date.date());
|
|
65
|
+
return { start: civil(project(rawStart)), end: civil(project(rawEnd)) };
|
|
66
|
+
}
|
|
67
|
+
|
|
30
68
|
// Helper function to convert Google Calendar all-day events to proper timezone
|
|
31
69
|
export function convertGoogleAllDayEvent(
|
|
32
70
|
startDate: string | undefined,
|
|
@@ -270,10 +270,6 @@ describe('application metadata coverage', () => {
|
|
|
270
270
|
});
|
|
271
271
|
|
|
272
272
|
it('covers the non-Next public application shells', () => {
|
|
273
|
-
const tanStackHead = readFileSync(
|
|
274
|
-
resolve(repoRoot, 'apps/tanstack-web/src/lib/platform/app-shell.ts'),
|
|
275
|
-
'utf8'
|
|
276
|
-
);
|
|
277
273
|
const mobileHead = readFileSync(
|
|
278
274
|
resolve(repoRoot, 'apps/mobile/web/index.html'),
|
|
279
275
|
'utf8'
|
|
@@ -282,8 +278,6 @@ describe('application metadata coverage', () => {
|
|
|
282
278
|
readFileSync(resolve(repoRoot, 'apps/docs/docs.json'), 'utf8')
|
|
283
279
|
) as { description?: string };
|
|
284
280
|
|
|
285
|
-
expect(tanStackHead).toContain('max-image-preview:large');
|
|
286
|
-
expect(tanStackHead).toContain("name: 'twitter:site'");
|
|
287
281
|
expect(mobileHead).toContain('name="robots" content="noindex, nofollow"');
|
|
288
282
|
expect(mobileHead).toContain('property="og:image"');
|
|
289
283
|
expect(docsConfig.description).toMatch(/Tuturuuu platform/);
|