@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.
- package/CHANGELOG.md +313 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +120 -1
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- 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
|
+
}
|
package/src/timezone.ts
ADDED
|
@@ -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
|
+
}
|