@tuturuuu/utils 0.0.3 → 0.6.1

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.
Files changed (186) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,418 @@
1
+ import type { Timeblock } from '@tuturuuu/types/primitives/Timeblock';
2
+ import dayjs, { type Dayjs } from 'dayjs';
3
+ import isBetween from 'dayjs/plugin/isBetween';
4
+ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
5
+ import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
6
+ import minMax from 'dayjs/plugin/minMax';
7
+ import timezone from 'dayjs/plugin/timezone';
8
+ import utc from 'dayjs/plugin/utc';
9
+
10
+ dayjs.extend(isSameOrBefore);
11
+ dayjs.extend(isSameOrAfter);
12
+ dayjs.extend(isBetween);
13
+ dayjs.extend(timezone);
14
+ dayjs.extend(minMax);
15
+ dayjs.extend(utc);
16
+
17
+ dayjs.tz.setDefault();
18
+
19
+ export function getDateStrings(dates: Date[]): string[] {
20
+ return dates.map((date) => dayjs(date).format('YYYY-MM-DD'));
21
+ }
22
+
23
+ export function datesToDateMatrix(dates?: Date[] | null): {
24
+ soonest: Dayjs;
25
+ latest: Dayjs;
26
+ } {
27
+ if (!dates || dates.length === 0) {
28
+ throw new Error('Invalid input');
29
+ }
30
+
31
+ const datesInDayjs = dates.map((date) => dayjs(date));
32
+ const sortedDates = datesInDayjs.sort((a, b) => a.diff(b));
33
+
34
+ const soonest = dayjs(sortedDates[0]);
35
+ const latest = dayjs(sortedDates[sortedDates.length - 1]).add(15, 'minutes');
36
+
37
+ return { soonest, latest };
38
+ }
39
+
40
+ export function datesToTimeMatrix(dates?: Date[] | null): {
41
+ soonest: Dayjs;
42
+ latest: Dayjs;
43
+ } {
44
+ if (!dates || dates.length === 0) {
45
+ throw new Error('Invalid input');
46
+ }
47
+
48
+ if (dates.length === 1)
49
+ return { soonest: dayjs(dates[0]), latest: dayjs(dates[0]) };
50
+
51
+ const now = dayjs();
52
+
53
+ const soonest =
54
+ dayjs.min(
55
+ dates.map((date) =>
56
+ dayjs(date)
57
+ .set('year', now.year())
58
+ .set('month', now.month())
59
+ .set('date', now.date())
60
+ )
61
+ ) ?? now;
62
+
63
+ const latest =
64
+ dayjs.max(
65
+ dates.map((date) =>
66
+ dayjs(date)
67
+ .set('year', now.year())
68
+ .set('month', now.month())
69
+ .set('date', now.date())
70
+ )
71
+ ) ?? now;
72
+
73
+ return {
74
+ soonest,
75
+ latest,
76
+ };
77
+ }
78
+
79
+ export function durationToTimeblocks(
80
+ dates: Date[],
81
+ tentative: boolean
82
+ ): Timeblock[] {
83
+ if (dates.length === 0) return [];
84
+
85
+ const timeblocks: Timeblock[] = [];
86
+
87
+ // Handle single date case (for single 15-minute timeblocks)
88
+ if (dates.length === 1) {
89
+ const date = dayjs(dates[0]);
90
+ const startTime = date.set('second', 0);
91
+ const endTime = startTime.add(15, 'minutes');
92
+
93
+ timeblocks.push({
94
+ date: startTime.format('YYYY-MM-DD'),
95
+ start_time: startTime.format('HH:mm:ssZ'),
96
+ end_time: endTime.format('HH:mm:ssZ'),
97
+ tentative,
98
+ });
99
+
100
+ return timeblocks;
101
+ }
102
+
103
+ // Handle two dates case (for duration-based timeblocks)
104
+ if (dates.length === 2) {
105
+ // If both dates are the same, treat as single date case
106
+ if (dayjs(dates[0]).isSame(dates[1], 'minute')) {
107
+ const date = dayjs(dates[0]);
108
+ const startTime = date.set('second', 0);
109
+ const endTime = startTime.add(15, 'minutes');
110
+
111
+ timeblocks.push({
112
+ date: startTime.format('YYYY-MM-DD'),
113
+ start_time: startTime.format('HH:mm:ssZ'),
114
+ end_time: endTime.format('HH:mm:ssZ'),
115
+ tentative,
116
+ });
117
+
118
+ return timeblocks;
119
+ }
120
+
121
+ const { soonest: soonestTime, latest: latestTime } =
122
+ datesToTimeMatrix(dates);
123
+ const { soonest: soonestDate, latest: latestDate } =
124
+ datesToDateMatrix(dates);
125
+
126
+ let start = soonestDate
127
+ .set('hour', soonestTime.hour())
128
+ .set('minute', soonestTime.minute())
129
+ .set('second', 0);
130
+
131
+ const end = latestDate
132
+ .set('hour', latestTime.hour())
133
+ .set('minute', latestTime.minute())
134
+ .set('second', 0);
135
+
136
+ // Use isSameOrBefore to handle cases where start and end are the same
137
+ while (start.isSameOrBefore(end)) {
138
+ const date = start.format('YYYY-MM-DD');
139
+
140
+ const startTime = dayjs(soonestTime);
141
+ const endTime = dayjs(latestTime).add(15, 'minutes');
142
+
143
+ timeblocks.push({
144
+ date,
145
+ start_time: startTime.format('HH:mm:ssZ'),
146
+ end_time: endTime.format('HH:mm:ssZ'),
147
+ tentative,
148
+ });
149
+
150
+ // Increment the date
151
+ start = start.add(1, 'day');
152
+ }
153
+ }
154
+
155
+ return timeblocks;
156
+ }
157
+
158
+ export function addTimeblocks(
159
+ prevTimeblocks: Timeblock[],
160
+ dates: Date[],
161
+ tentative: boolean
162
+ ): Timeblock[] {
163
+ // Generate new timeblocks from dates
164
+ const newTimeblocks = durationToTimeblocks(dates, tentative);
165
+ if (newTimeblocks.length === 0) return prevTimeblocks;
166
+
167
+ // Start with all existing timeblocks
168
+ let result: Timeblock[] = [...prevTimeblocks];
169
+
170
+ // Merge each new timeblock with existing ones
171
+ for (const newTimeblock of newTimeblocks) {
172
+ if (!isValidTimeblock(newTimeblock)) continue;
173
+
174
+ const mergedResult: Timeblock[] = [];
175
+ let wasProcessed = false;
176
+
177
+ for (const existingTimeblock of result) {
178
+ if (!isValidTimeblock(existingTimeblock)) continue;
179
+
180
+ // Check if timeblocks are on the same date and can potentially be merged
181
+ if (existingTimeblock.date === newTimeblock.date) {
182
+ const merged = mergeTimeblocks(existingTimeblock, newTimeblock);
183
+ mergedResult.push(...merged);
184
+ wasProcessed = true;
185
+ } else {
186
+ mergedResult.push(existingTimeblock);
187
+ }
188
+ }
189
+
190
+ // If the new timeblock wasn't processed (no overlap with existing ones), add it separately
191
+ if (!wasProcessed) {
192
+ mergedResult.push(newTimeblock);
193
+ }
194
+
195
+ result = mergedResult;
196
+ }
197
+
198
+ // Sort the result by date and time
199
+ return result.sort((a, b) => {
200
+ const aDateTime = dayjs(`${a.date} ${a.start_time}`);
201
+ const bDateTime = dayjs(`${b.date} ${b.start_time}`);
202
+ return aDateTime.diff(bDateTime);
203
+ });
204
+ }
205
+
206
+ export function removeTimeblocks(
207
+ prevTimeblocks: Timeblock[],
208
+ dates: Date[]
209
+ ): Timeblock[] {
210
+ // Return the previous timeblocks if the dates are empty
211
+ if (!dates || dates.length === 0) {
212
+ return prevTimeblocks;
213
+ }
214
+
215
+ // Return empty array if no timeblocks to process
216
+ if (!prevTimeblocks || prevTimeblocks.length === 0) {
217
+ return [];
218
+ }
219
+
220
+ // Always use min/max day logic first
221
+ const { soonest: soonestDate, latest: latestDate } = datesToDateMatrix(dates);
222
+ const { soonest: soonestTime, latest: latestTime } = datesToTimeMatrix(dates);
223
+
224
+ // Handle single date removal
225
+ if (dates.length === 1) {
226
+ const removalStart = soonestDate
227
+ .set('hour', soonestTime.hour())
228
+ .set('minute', soonestTime.minute())
229
+ .set('second', 0);
230
+ const removalEnd = removalStart.add(15, 'minutes');
231
+
232
+ return removeTimeblocksInRange(prevTimeblocks, removalStart, removalEnd);
233
+ }
234
+
235
+ // Handle multi-day removal - process each day separately
236
+ if (dates.length === 2) {
237
+ // If it's the same day, treat as single day removal
238
+ if (soonestDate.isSame(latestDate, 'day')) {
239
+ const removalStart = soonestDate
240
+ .set('hour', soonestTime.hour())
241
+ .set('minute', soonestTime.minute())
242
+ .set('second', 0);
243
+ let removalEnd = latestDate
244
+ .set('hour', latestTime.hour())
245
+ .set('minute', latestTime.minute())
246
+ .set('second', 0);
247
+
248
+ // Fix for UI sending times like 8:59:59 instead of 9:00:00
249
+ // If the end time is very close to the next hour boundary, round it up
250
+ if (removalEnd.minute() >= 59 && latestTime.second() >= 30) {
251
+ removalEnd = removalEnd.add(1, 'hour').minute(0);
252
+ }
253
+
254
+ return removeTimeblocksInRange(prevTimeblocks, removalStart, removalEnd);
255
+ }
256
+
257
+ // Multi-day removal - remove the same time slot on each day
258
+ let result = [...prevTimeblocks];
259
+
260
+ // Get the time range (hours and minutes) from the min/max times
261
+ const startHour = soonestTime.hour();
262
+ const startMinute = soonestTime.minute();
263
+ let endHour = latestTime.hour();
264
+ let endMinute = latestTime.minute();
265
+
266
+ // Fix for UI sending times like 8:59:59 instead of 9:00:00
267
+ // If the end time is very close to the next hour boundary, round it up
268
+ if (endMinute >= 59 && latestTime.second() >= 30) {
269
+ endHour = endHour + 1;
270
+ endMinute = 0;
271
+ }
272
+
273
+ // Iterate through each day in the range using min/max dates
274
+ let currentDate = soonestDate.startOf('day');
275
+ const lastDate = latestDate.startOf('day');
276
+
277
+ while (currentDate.isSameOrBefore(lastDate, 'day')) {
278
+ const dayRemovalStart = currentDate
279
+ .hour(startHour)
280
+ .minute(startMinute)
281
+ .second(0);
282
+ const dayRemovalEnd = currentDate
283
+ .hour(endHour)
284
+ .minute(endMinute)
285
+ .second(0);
286
+
287
+ result = removeTimeblocksInRange(result, dayRemovalStart, dayRemovalEnd);
288
+ currentDate = currentDate.add(1, 'day');
289
+ }
290
+
291
+ return result;
292
+ }
293
+
294
+ // Handle multiple dates (more than 2) - treat as individual removals
295
+ // Sort dates to ensure consistent min/max behavior
296
+ const sortedDates = dates.sort((a, b) => dayjs(a).diff(dayjs(b)));
297
+ let result = [...prevTimeblocks];
298
+
299
+ for (const date of sortedDates) {
300
+ const removalStart = dayjs(date).set('second', 0);
301
+ const removalEnd = removalStart.add(15, 'minutes');
302
+ result = removeTimeblocksInRange(result, removalStart, removalEnd);
303
+ }
304
+
305
+ return result;
306
+ }
307
+
308
+ // Helper function to remove timeblocks within a specific time range
309
+ function removeTimeblocksInRange(
310
+ timeblocks: Timeblock[],
311
+ removalStart: Dayjs,
312
+ removalEnd: Dayjs
313
+ ): Timeblock[] {
314
+ const result: Timeblock[] = [];
315
+
316
+ for (const timeblock of timeblocks) {
317
+ const splitTimeblocks = splitTimeblockByRemovalRange(
318
+ timeblock,
319
+ removalStart,
320
+ removalEnd.add(15, 'minutes')
321
+ );
322
+ result.push(...splitTimeblocks);
323
+ }
324
+
325
+ return result;
326
+ }
327
+
328
+ function mergeTimeblocks(
329
+ existing: Timeblock,
330
+ newTimeblock: Timeblock
331
+ ): Timeblock[] {
332
+ const existingStart = dayjs(`${existing.date} ${existing.start_time}`);
333
+ const existingEnd = dayjs(`${existing.date} ${existing.end_time}`);
334
+
335
+ const newStart = dayjs(`${newTimeblock.date} ${newTimeblock.start_time}`);
336
+ const newEnd = dayjs(`${newTimeblock.date} ${newTimeblock.end_time}`);
337
+
338
+ if (existingEnd.isBefore(newStart) || existingStart.isAfter(newEnd)) {
339
+ return [existing, newTimeblock];
340
+ }
341
+
342
+ if (existing.tentative === newTimeblock.tentative) {
343
+ // Merge timeblocks by taking min start and max end
344
+ const mergedStart = existingStart.isBefore(newStart)
345
+ ? existingStart
346
+ : newStart;
347
+ const mergedEnd = existingEnd.isAfter(newEnd) ? existingEnd : newEnd;
348
+
349
+ const mergedTimeblock: Timeblock = {
350
+ ...existing, // Keep other properties from existing
351
+ start_time: mergedStart.format('HH:mm:ssZ'),
352
+ end_time: mergedEnd.format('HH:mm:ssZ'),
353
+ };
354
+
355
+ return [mergedTimeblock];
356
+ } else {
357
+ const remainingParts = splitTimeblockByRemovalRange(
358
+ existing,
359
+ newStart,
360
+ newEnd
361
+ );
362
+
363
+ return [...remainingParts, newTimeblock].sort((a, b) => {
364
+ const aTime = dayjs(`${a.date} ${a.start_time}`);
365
+ const bTime = dayjs(`${b.date} ${b.start_time}`);
366
+ return aTime.diff(bTime);
367
+ });
368
+ }
369
+ }
370
+
371
+ function splitTimeblockByRemovalRange(
372
+ timeblock: Timeblock,
373
+ removalStart: Dayjs,
374
+ removalEnd: Dayjs
375
+ ): Timeblock[] {
376
+ const timeblockStart = dayjs(`${timeblock.date} ${timeblock.start_time}`);
377
+ const timeblockEnd = dayjs(`${timeblock.date} ${timeblock.end_time}`);
378
+
379
+ // Timeblock is completely outside the removal range
380
+ if (
381
+ timeblockEnd.isBefore(removalStart) ||
382
+ timeblockStart.isAfter(removalEnd)
383
+ ) {
384
+ return [timeblock];
385
+ }
386
+
387
+ // Timeblock overlaps with removal range - need to split
388
+ const remainingParts: Timeblock[] = [];
389
+
390
+ // Keep the part before the removal range (if any)
391
+ if (timeblockStart.isBefore(removalStart)) {
392
+ remainingParts.push({
393
+ ...timeblock,
394
+ id: undefined, // Remove ID for new timeblock
395
+ end_time: removalStart.format('HH:mm:ssZ'),
396
+ });
397
+ }
398
+
399
+ // Keep the part after the removal range (if any)
400
+ if (timeblockEnd.isAfter(removalEnd)) {
401
+ remainingParts.push({
402
+ ...timeblock,
403
+ id: undefined, // Remove ID for new timeblock
404
+ start_time: removalEnd.format('HH:mm:ssZ'),
405
+ });
406
+ }
407
+
408
+ return remainingParts;
409
+ }
410
+
411
+ function isValidTimeblock(timeblock: Timeblock): boolean {
412
+ return !!(
413
+ timeblock?.date &&
414
+ timeblock?.start_time &&
415
+ timeblock?.end_time &&
416
+ timeblock?.tentative !== undefined
417
+ );
418
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Timezone utilities for handling timezone validation and resolution.
3
+ *
4
+ * This module provides helpers for:
5
+ * - Validating IANA timezone identifiers
6
+ * - Detecting browser timezone
7
+ * - Resolving "auto" timezone settings to actual values
8
+ */
9
+
10
+ /**
11
+ * Cache of valid timezone identifiers for performance.
12
+ * Lazily initialized on first use.
13
+ */
14
+ let cachedTimezones: Set<string> | null = null;
15
+
16
+ /**
17
+ * Gets the set of valid IANA timezone identifiers.
18
+ * Uses Intl.supportedValuesOf when available, falls back to a basic check.
19
+ */
20
+ function getValidTimezones(): Set<string> {
21
+ if (cachedTimezones) return cachedTimezones;
22
+
23
+ try {
24
+ // Modern browsers and Node.js 18+ support this
25
+ if (typeof Intl !== 'undefined' && 'supportedValuesOf' in Intl) {
26
+ const timezones = (
27
+ Intl as unknown as {
28
+ supportedValuesOf: (key: string) => string[];
29
+ }
30
+ ).supportedValuesOf('timeZone');
31
+ cachedTimezones = new Set(timezones);
32
+ return cachedTimezones;
33
+ }
34
+ } catch {
35
+ // Fall through to basic validation
36
+ }
37
+
38
+ // Fallback: empty set means we'll use try/catch validation
39
+ cachedTimezones = new Set();
40
+ return cachedTimezones;
41
+ }
42
+
43
+ /**
44
+ * Validates if a string is a valid IANA timezone identifier.
45
+ *
46
+ * @param tz - The timezone string to validate (e.g., 'America/New_York', 'Asia/Ho_Chi_Minh')
47
+ * @returns true if the timezone is valid, false otherwise
48
+ *
49
+ * @example
50
+ * isValidTimezone('America/New_York') // true
51
+ * isValidTimezone('Asia/Ho_Chi_Minh') // true
52
+ * isValidTimezone('UTC') // true
53
+ * isValidTimezone('Invalid/Timezone') // false
54
+ * isValidTimezone('PST') // false (abbreviations are not valid IANA identifiers)
55
+ */
56
+ export function isValidTimezone(tz: string): boolean {
57
+ if (!tz || typeof tz !== 'string') return false;
58
+
59
+ const validTimezones = getValidTimezones();
60
+
61
+ // If we have a cached set and it includes the timezone, return true
62
+ if (validTimezones.size > 0 && validTimezones.has(tz)) {
63
+ return true;
64
+ }
65
+
66
+ // Always try the DateTimeFormat fallback as the authoritative check
67
+ // This handles cases like 'UTC' which may not be in supportedValuesOf
68
+ // but is still valid for DateTimeFormat
69
+ try {
70
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Gets the browser's detected timezone.
79
+ *
80
+ * @returns The IANA timezone identifier (e.g., 'America/New_York')
81
+ * or 'UTC' if detection fails or running on server
82
+ *
83
+ * @example
84
+ * // In browser with system timezone set to Ho Chi Minh
85
+ * getBrowserTimezone() // 'Asia/Ho_Chi_Minh'
86
+ *
87
+ * // On server or when detection fails
88
+ * getBrowserTimezone() // 'UTC'
89
+ */
90
+ export function getBrowserTimezone(): string {
91
+ try {
92
+ if (
93
+ typeof Intl !== 'undefined' &&
94
+ typeof Intl.DateTimeFormat === 'function'
95
+ ) {
96
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
97
+ if (timezone && isValidTimezone(timezone)) {
98
+ return timezone;
99
+ }
100
+ }
101
+ } catch {
102
+ // Fallback to UTC
103
+ }
104
+ return 'UTC';
105
+ }
106
+
107
+ /**
108
+ * Special value indicating automatic timezone detection.
109
+ */
110
+ export const AUTO_TIMEZONE = 'auto' as const;
111
+
112
+ /**
113
+ * Resolves a timezone setting to an actual IANA timezone identifier.
114
+ *
115
+ * This handles the "auto" case by detecting the browser timezone on the client,
116
+ * and validates explicit timezone values. Invalid or missing values fall back to UTC.
117
+ *
118
+ * @param timezone - The timezone setting, which can be:
119
+ * - 'auto' - Resolve to browser timezone
120
+ * - A valid IANA identifier - Return as-is
121
+ * - null/undefined/invalid - Fall back to UTC
122
+ *
123
+ * @returns A valid IANA timezone identifier
124
+ *
125
+ * @example
126
+ * // On client with browser timezone 'Asia/Ho_Chi_Minh'
127
+ * resolveAutoTimezone('auto') // 'Asia/Ho_Chi_Minh'
128
+ *
129
+ * // Explicit valid timezone
130
+ * resolveAutoTimezone('America/New_York') // 'America/New_York'
131
+ *
132
+ * // Invalid or missing
133
+ * resolveAutoTimezone(null) // 'UTC'
134
+ * resolveAutoTimezone('Invalid') // 'UTC'
135
+ */
136
+ export function resolveAutoTimezone(timezone?: string | null): string {
137
+ // Handle auto detection
138
+ if (timezone === AUTO_TIMEZONE || timezone === 'auto') {
139
+ return getBrowserTimezone();
140
+ }
141
+
142
+ // Validate explicit timezone
143
+ if (timezone && isValidTimezone(timezone)) {
144
+ return timezone;
145
+ }
146
+
147
+ // Fallback to UTC
148
+ return 'UTC';
149
+ }
150
+
151
+ /**
152
+ * Gets a timezone offset string for a given IANA timezone at a specific date.
153
+ *
154
+ * @param timezone - The IANA timezone identifier
155
+ * @param date - The date to get the offset for (defaults to now)
156
+ * @returns The offset string in format '+HH:MM' or '-HH:MM'
157
+ *
158
+ * @example
159
+ * getTimezoneOffset('America/New_York') // '-05:00' or '-04:00' depending on DST
160
+ * getTimezoneOffset('Asia/Ho_Chi_Minh') // '+07:00'
161
+ */
162
+ export function getTimezoneOffset(
163
+ timezone: string,
164
+ date: Date = new Date()
165
+ ): string {
166
+ try {
167
+ const formatter = new Intl.DateTimeFormat('en-US', {
168
+ timeZone: timezone,
169
+ timeZoneName: 'longOffset',
170
+ });
171
+
172
+ const parts = formatter.formatToParts(date);
173
+ const offsetPart = parts.find((p) => p.type === 'timeZoneName');
174
+
175
+ if (offsetPart?.value) {
176
+ // Extract offset from format like "GMT+07:00" or "GMT-05:00"
177
+ const match = offsetPart.value.match(/GMT([+-]\d{2}:\d{2})/);
178
+ if (match?.[1]) {
179
+ return match[1];
180
+ }
181
+ // Handle "GMT" (UTC) case
182
+ if (offsetPart.value === 'GMT') {
183
+ return '+00:00';
184
+ }
185
+ }
186
+ } catch {
187
+ // Fallback
188
+ }
189
+ return '+00:00';
190
+ }