@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,220 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
|
|
3
|
+
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
4
|
+
export type HoursInterval = Readonly<{ start: string; end: string }>;
|
|
5
|
+
/** Missing weekday means unknown for staff defaults and inheritance for overrides. */
|
|
6
|
+
export type HoursWeek = Partial<Record<Weekday, readonly HoursInterval[]>>;
|
|
7
|
+
/** Opaque storage receipt. Never parse through Number or Date. */
|
|
8
|
+
export type HoursRevision = string;
|
|
9
|
+
export type ConfirmedHoursFrame = Readonly<{
|
|
10
|
+
timeZone: string;
|
|
11
|
+
confirmed: true;
|
|
12
|
+
}>;
|
|
13
|
+
export type StaffHours = Readonly<{
|
|
14
|
+
revision: HoursRevision;
|
|
15
|
+
frame: ConfirmedHoursFrame;
|
|
16
|
+
week: HoursWeek;
|
|
17
|
+
}>;
|
|
18
|
+
export type TeacherHoursOverride = Readonly<{
|
|
19
|
+
revision: HoursRevision;
|
|
20
|
+
week: HoursWeek;
|
|
21
|
+
}>;
|
|
22
|
+
export type EffectiveHoursDay = Readonly<{
|
|
23
|
+
day: Weekday;
|
|
24
|
+
source: 'default' | 'override' | 'unknown';
|
|
25
|
+
state: 'hours' | 'closed' | 'unknown';
|
|
26
|
+
intervals: readonly HoursInterval[];
|
|
27
|
+
}>;
|
|
28
|
+
const weekdays: readonly Weekday[] = [1, 2, 3, 4, 5, 6, 7];
|
|
29
|
+
|
|
30
|
+
function minute(value: string, end = false): number {
|
|
31
|
+
if (end && value === '24:00') return 1440;
|
|
32
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) {
|
|
33
|
+
throw new Error('Invalid teacher-hours clock');
|
|
34
|
+
}
|
|
35
|
+
const [hour, minutes] = value.split(':').map(Number);
|
|
36
|
+
return hour! * 60 + minutes!;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateHoursRevision(revision: HoursRevision): void {
|
|
40
|
+
if (
|
|
41
|
+
typeof revision !== 'string' ||
|
|
42
|
+
!revision.trim() ||
|
|
43
|
+
revision.length > 512
|
|
44
|
+
) {
|
|
45
|
+
throw new Error('Invalid teacher-hours revision');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateHoursFrame(frame: ConfirmedHoursFrame): void {
|
|
50
|
+
if (
|
|
51
|
+
frame?.confirmed !== true ||
|
|
52
|
+
typeof frame.timeZone !== 'string' ||
|
|
53
|
+
frame.timeZone === 'auto' ||
|
|
54
|
+
/^[+-]/.test(frame.timeZone)
|
|
55
|
+
) {
|
|
56
|
+
throw new Error('Teacher-hours timezone must be explicitly confirmed');
|
|
57
|
+
}
|
|
58
|
+
// Intl rejects non-IANA identifiers; UTC and recognized IANA aliases are valid.
|
|
59
|
+
new Intl.DateTimeFormat('en', { timeZone: frame.timeZone }).format(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function validateHoursWeek(week: HoursWeek): void {
|
|
63
|
+
if (!week || typeof week !== 'object' || Array.isArray(week)) {
|
|
64
|
+
throw new Error('Invalid teacher-hours week');
|
|
65
|
+
}
|
|
66
|
+
for (const [key, intervals] of Object.entries(week)) {
|
|
67
|
+
if (
|
|
68
|
+
!/^[1-7]$/.test(key) ||
|
|
69
|
+
!Array.isArray(intervals) ||
|
|
70
|
+
intervals.length > 24
|
|
71
|
+
) {
|
|
72
|
+
throw new Error('Invalid teacher-hours weekday');
|
|
73
|
+
}
|
|
74
|
+
let previousEnd = -1;
|
|
75
|
+
for (const interval of intervals) {
|
|
76
|
+
const start = minute(interval.start);
|
|
77
|
+
const end = minute(interval.end, true);
|
|
78
|
+
if (end <= start || start < previousEnd) {
|
|
79
|
+
throw new Error('Teacher-hours intervals must be sorted and disjoint');
|
|
80
|
+
}
|
|
81
|
+
previousEnd = end;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveTeacherHours(
|
|
87
|
+
defaults: StaffHours | null,
|
|
88
|
+
override: TeacherHoursOverride | null
|
|
89
|
+
): readonly EffectiveHoursDay[] {
|
|
90
|
+
if (defaults) {
|
|
91
|
+
validateHoursRevision(defaults.revision);
|
|
92
|
+
validateHoursFrame(defaults.frame);
|
|
93
|
+
validateHoursWeek(defaults.week);
|
|
94
|
+
}
|
|
95
|
+
if (override) {
|
|
96
|
+
validateHoursRevision(override.revision);
|
|
97
|
+
validateHoursWeek(override.week);
|
|
98
|
+
}
|
|
99
|
+
return weekdays.map((day) => {
|
|
100
|
+
const own = override?.week[day];
|
|
101
|
+
const inherited = defaults?.week[day];
|
|
102
|
+
// An override cannot establish an unconfirmed/missing staff timezone frame.
|
|
103
|
+
const intervals = defaults ? (own ?? inherited) : undefined;
|
|
104
|
+
return {
|
|
105
|
+
day,
|
|
106
|
+
source:
|
|
107
|
+
intervals === undefined ? 'unknown' : own ? 'override' : 'default',
|
|
108
|
+
state:
|
|
109
|
+
intervals === undefined
|
|
110
|
+
? 'unknown'
|
|
111
|
+
: intervals.length
|
|
112
|
+
? 'hours'
|
|
113
|
+
: 'closed',
|
|
114
|
+
intervals: intervals?.map((interval) => ({ ...interval })) ?? [],
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Pure receipt validation, not a database CAS or authorization implementation. */
|
|
120
|
+
export function validateHoursWriteReceipt(
|
|
121
|
+
expectedRevision: HoursRevision | null,
|
|
122
|
+
returnedRevision: HoursRevision
|
|
123
|
+
): void {
|
|
124
|
+
if (expectedRevision !== null) validateHoursRevision(expectedRevision);
|
|
125
|
+
validateHoursRevision(returnedRevision);
|
|
126
|
+
if (expectedRevision === returnedRevision) {
|
|
127
|
+
throw new Error('Teacher-hours write did not advance its revision');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reset is a revisioned empty override, never deletion back to an absent row. */
|
|
132
|
+
export function resetTeacherHoursOverride(
|
|
133
|
+
expectedRevision: HoursRevision | null,
|
|
134
|
+
returnedRevision: HoursRevision
|
|
135
|
+
): TeacherHoursOverride {
|
|
136
|
+
validateHoursWriteReceipt(expectedRevision, returnedRevision);
|
|
137
|
+
return { revision: returnedRevision, week: {} };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type HoursSlot = Readonly<{
|
|
141
|
+
date: string;
|
|
142
|
+
start: string;
|
|
143
|
+
endDate: string;
|
|
144
|
+
end: string;
|
|
145
|
+
}>;
|
|
146
|
+
export type HoursSlotDecision =
|
|
147
|
+
| Readonly<{ state: 'allowed'; startInstant: string; endInstant: string }>
|
|
148
|
+
| Readonly<{ state: 'closed' | 'unknown' | 'invalid' }>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Staff-clock slot only: callers must convert other source frames explicitly.
|
|
152
|
+
* Reject DST gaps/folds and transition-crossing slots rather than choosing offsets.
|
|
153
|
+
* Overnight hours are represented explicitly on both weekdays; no inferred widening.
|
|
154
|
+
*/
|
|
155
|
+
export function assessTeacherHoursSlot(
|
|
156
|
+
defaults: StaffHours | null,
|
|
157
|
+
override: TeacherHoursOverride | null,
|
|
158
|
+
slot: HoursSlot
|
|
159
|
+
): HoursSlotDecision {
|
|
160
|
+
if (!defaults) return { state: 'unknown' };
|
|
161
|
+
try {
|
|
162
|
+
const week = resolveTeacherHours(defaults, override);
|
|
163
|
+
if (
|
|
164
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(slot.date) ||
|
|
165
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(slot.endDate)
|
|
166
|
+
) {
|
|
167
|
+
return { state: 'invalid' };
|
|
168
|
+
}
|
|
169
|
+
minute(slot.start);
|
|
170
|
+
minute(slot.end);
|
|
171
|
+
const start = Temporal.PlainDateTime.from(`${slot.date}T${slot.start}`);
|
|
172
|
+
const end = Temporal.PlainDateTime.from(`${slot.endDate}T${slot.end}`);
|
|
173
|
+
const civilMinutes = start.until(end, { largestUnit: 'minutes' }).minutes;
|
|
174
|
+
if (civilMinutes <= 0 || civilMinutes > 7 * 1440)
|
|
175
|
+
return { state: 'invalid' };
|
|
176
|
+
const zone = defaults.frame.timeZone;
|
|
177
|
+
const startZoned = start.toZonedDateTime(zone, {
|
|
178
|
+
disambiguation: 'reject',
|
|
179
|
+
});
|
|
180
|
+
const endZoned = end.toZonedDateTime(zone, { disambiguation: 'reject' });
|
|
181
|
+
if (
|
|
182
|
+
endZoned.epochMilliseconds - startZoned.epochMilliseconds !==
|
|
183
|
+
civilMinutes * 60_000
|
|
184
|
+
) {
|
|
185
|
+
return { state: 'invalid' };
|
|
186
|
+
}
|
|
187
|
+
let date = start.toPlainDate();
|
|
188
|
+
let unknown = false;
|
|
189
|
+
let closed = false;
|
|
190
|
+
while (Temporal.PlainDate.compare(date, end.toPlainDate()) <= 0) {
|
|
191
|
+
const sameStart =
|
|
192
|
+
Temporal.PlainDate.compare(date, start.toPlainDate()) === 0;
|
|
193
|
+
const sameEnd = Temporal.PlainDate.compare(date, end.toPlainDate()) === 0;
|
|
194
|
+
const from = sameStart ? minute(slot.start) : 0;
|
|
195
|
+
const to = sameEnd ? minute(slot.end) : 1440;
|
|
196
|
+
if (from < to) {
|
|
197
|
+
const day = week[date.dayOfWeek - 1]!;
|
|
198
|
+
if (day.state === 'unknown') unknown = true;
|
|
199
|
+
else {
|
|
200
|
+
let covered = from;
|
|
201
|
+
for (const interval of day.intervals) {
|
|
202
|
+
if (minute(interval.start) > covered) break;
|
|
203
|
+
covered = Math.max(covered, minute(interval.end, true));
|
|
204
|
+
}
|
|
205
|
+
if (covered < to) closed = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
date = date.add({ days: 1 });
|
|
209
|
+
}
|
|
210
|
+
if (closed) return { state: 'closed' };
|
|
211
|
+
if (unknown) return { state: 'unknown' };
|
|
212
|
+
return {
|
|
213
|
+
state: 'allowed',
|
|
214
|
+
startInstant: startZoned.toInstant().toString(),
|
|
215
|
+
endInstant: endZoned.toInstant().toString(),
|
|
216
|
+
};
|
|
217
|
+
} catch {
|
|
218
|
+
return { state: 'invalid' };
|
|
219
|
+
}
|
|
220
|
+
}
|