@tuturuuu/utils 0.0.3 → 0.6.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.
Files changed (186) hide show
  1. package/CHANGELOG.md +305 -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,245 @@
1
+ import {
2
+ type QueryClient,
3
+ useMutation,
4
+ useQueryClient,
5
+ } from '@tanstack/react-query';
6
+ import {
7
+ getWorkspaceTaskRelationships,
8
+ updateWorkspaceTask,
9
+ } from '@tuturuuu/internal-api/tasks';
10
+ import type { Task } from '@tuturuuu/types/primitives/Task';
11
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
12
+ import {
13
+ isTaskBoardCompletedStatus,
14
+ isTaskBoardResolvedStatus,
15
+ isTaskBoardTerminalStatus,
16
+ } from '../task-list-status';
17
+ import { transformTaskRecord } from './transformers';
18
+
19
+ type ReorderTaskCacheInput = {
20
+ taskId: string;
21
+ newListId: string;
22
+ newSortKey: number;
23
+ targetListStatus?: TaskList['status'] | null;
24
+ localMutationAt?: number;
25
+ };
26
+
27
+ type LocallyMutatedTask = Task & { _localMutationAt: number };
28
+ type ReorderTaskMutationInput = {
29
+ taskId: string;
30
+ newListId: string;
31
+ newSortKey: number;
32
+ optimisticPreviousTasks?: Task[];
33
+ optimisticPreviousFullTasks?: Task[];
34
+ };
35
+
36
+ export function mergeOptimisticReorderedTaskIntoCache(
37
+ tasks: Task[] | undefined,
38
+ {
39
+ taskId,
40
+ newListId,
41
+ newSortKey,
42
+ targetListStatus,
43
+ localMutationAt = Date.now(),
44
+ }: ReorderTaskCacheInput
45
+ ) {
46
+ if (!tasks) return tasks;
47
+
48
+ const targetIsCompleted = isTaskBoardCompletedStatus(targetListStatus);
49
+ const targetIsTerminal = isTaskBoardTerminalStatus(targetListStatus);
50
+ const mutationTimestamp = new Date(localMutationAt).toISOString();
51
+
52
+ return tasks.map((task) => {
53
+ if (task.id !== taskId) return task;
54
+
55
+ return {
56
+ ...task,
57
+ list_id: newListId,
58
+ sort_key: newSortKey,
59
+ completed: targetIsCompleted,
60
+ completed_at: targetIsCompleted
61
+ ? (task.completed_at ?? mutationTimestamp)
62
+ : null,
63
+ closed_at: targetIsTerminal
64
+ ? (task.closed_at ?? mutationTimestamp)
65
+ : null,
66
+ _localMutationAt: localMutationAt,
67
+ } as LocallyMutatedTask;
68
+ });
69
+ }
70
+
71
+ export function mergeServerReorderedTaskIntoCache(
72
+ tasks: Task[] | undefined,
73
+ updatedTask: Task,
74
+ localMutationAt = Date.now()
75
+ ) {
76
+ if (!tasks) return tasks;
77
+
78
+ return tasks.map((task) => {
79
+ if (task.id !== updatedTask.id) return task;
80
+
81
+ return {
82
+ ...task,
83
+ ...updatedTask,
84
+ _localMutationAt: localMutationAt,
85
+ } as LocallyMutatedTask;
86
+ });
87
+ }
88
+
89
+ function setReorderedTaskCache(
90
+ queryClient: QueryClient,
91
+ boardId: string,
92
+ updater: (tasks: Task[] | undefined) => Task[] | undefined
93
+ ) {
94
+ queryClient.setQueryData(['tasks', boardId], updater);
95
+
96
+ if (queryClient.getQueryData<Task[]>(['tasks-full', boardId])) {
97
+ queryClient.setQueryData(['tasks-full', boardId], updater);
98
+ }
99
+ }
100
+
101
+ // Reorder task within the same list or move to a different list with specific position
102
+ export async function reorderTask(
103
+ wsId: string,
104
+ taskId: string,
105
+ newListId: string,
106
+ newSortKey: number
107
+ ): Promise<Task> {
108
+ const baseUrl =
109
+ typeof window !== 'undefined' ? window.location.origin : undefined;
110
+
111
+ const { task } = await updateWorkspaceTask(
112
+ wsId,
113
+ taskId,
114
+ {
115
+ list_id: newListId,
116
+ sort_key: newSortKey,
117
+ },
118
+ baseUrl ? { baseUrl } : undefined
119
+ );
120
+
121
+ return transformTaskRecord(task) as Task;
122
+ }
123
+
124
+ // React Query hook for reordering tasks
125
+ export function useReorderTask(boardId: string, wsId: string) {
126
+ const queryClient = useQueryClient();
127
+
128
+ return useMutation({
129
+ mutationFn: async ({
130
+ taskId,
131
+ newListId,
132
+ newSortKey,
133
+ }: ReorderTaskMutationInput) => {
134
+ return reorderTask(wsId, taskId, newListId, newSortKey);
135
+ },
136
+ onMutate: ({
137
+ taskId,
138
+ newListId,
139
+ newSortKey,
140
+ optimisticPreviousTasks,
141
+ optimisticPreviousFullTasks,
142
+ }) => {
143
+ // Snapshot the previous value
144
+ const previousTasks = queryClient.getQueryData<Task[]>([
145
+ 'tasks',
146
+ boardId,
147
+ ]);
148
+ const previousFullTasks = queryClient.getQueryData<Task[]>([
149
+ 'tasks-full',
150
+ boardId,
151
+ ]);
152
+
153
+ // Cancel any outgoing refetches without delaying the optimistic landing.
154
+ void queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
155
+
156
+ // Check if moving to a done or closed list
157
+ const targetList = queryClient.getQueryData(['task_lists', boardId]) as
158
+ | TaskList[]
159
+ | undefined;
160
+ const list = targetList?.find((l) => l.id === newListId);
161
+ const isCompletionList = isTaskBoardResolvedStatus(list?.status);
162
+
163
+ // If moving to completion list, start fetching blocked task IDs asynchronously
164
+ // Don't await here to avoid blocking the optimistic update
165
+ let blockedTaskIdsPromise: Promise<string[]> | null = null;
166
+ if (isCompletionList) {
167
+ const baseUrl =
168
+ typeof window !== 'undefined' ? window.location.origin : undefined;
169
+ blockedTaskIdsPromise = Promise.resolve(
170
+ getWorkspaceTaskRelationships(
171
+ wsId,
172
+ taskId,
173
+ baseUrl ? { baseUrl } : undefined
174
+ )
175
+ )
176
+ .then((relationships) =>
177
+ (relationships?.blocking ?? []).map((task) => task.id)
178
+ )
179
+ .catch((err: unknown) => {
180
+ console.error('Failed to fetch blocked task IDs:', err);
181
+ return []; // Return empty array on error to prevent breaking the flow
182
+ });
183
+ }
184
+
185
+ // Optimistically update the task immediately (not blocked by the fetch above)
186
+ setReorderedTaskCache(queryClient, boardId, (old) =>
187
+ mergeOptimisticReorderedTaskIntoCache(old, {
188
+ taskId,
189
+ newListId,
190
+ newSortKey,
191
+ targetListStatus: list?.status,
192
+ })
193
+ );
194
+
195
+ return {
196
+ previousTasks: optimisticPreviousTasks ?? previousTasks,
197
+ previousFullTasks: optimisticPreviousFullTasks ?? previousFullTasks,
198
+ blockedTaskIdsPromise,
199
+ };
200
+ },
201
+ onError: (err, _, context) => {
202
+ if (context?.previousTasks) {
203
+ queryClient.setQueryData(['tasks', boardId], context.previousTasks);
204
+ }
205
+ if (context?.previousFullTasks) {
206
+ queryClient.setQueryData(
207
+ ['tasks-full', boardId],
208
+ context.previousFullTasks
209
+ );
210
+ }
211
+
212
+ console.error('Failed to reorder task:', err);
213
+ },
214
+ onSuccess: async (updatedTask, variables, context) => {
215
+ // Update the cache with the server response
216
+ setReorderedTaskCache(queryClient, boardId, (old) =>
217
+ mergeServerReorderedTaskIntoCache(old, updatedTask)
218
+ );
219
+
220
+ // If task was moved to done/closed list (has completed_at or closed_at set),
221
+ // invalidate task relationships to reflect removed blocking relationships
222
+ if (updatedTask.completed_at || updatedTask.closed_at) {
223
+ // Invalidate the completed/closed task's relationships
224
+ await queryClient.invalidateQueries({
225
+ queryKey: ['task-relationships', variables.taskId],
226
+ });
227
+
228
+ // Await the blockedTaskIdsPromise to get the list of blocked tasks
229
+ // Then invalidate all blocked tasks' relationships (they're now unblocked)
230
+ if (context?.blockedTaskIdsPromise) {
231
+ const blockedTaskIds = await context.blockedTaskIdsPromise;
232
+ if (blockedTaskIds.length > 0) {
233
+ await Promise.all(
234
+ blockedTaskIds.map((blockedTaskId) =>
235
+ queryClient.invalidateQueries({
236
+ queryKey: ['task-relationships', blockedTaskId],
237
+ })
238
+ )
239
+ );
240
+ }
241
+ }
242
+ }
243
+ },
244
+ });
245
+ }
@@ -0,0 +1,149 @@
1
+ import type { Task, TaskAssignee } from '@tuturuuu/types/primitives/Task';
2
+ import type { User } from '@tuturuuu/types/primitives/User';
3
+
4
+ type TaskUser = NonNullable<Task['assignees']>[number];
5
+ type TaskLabel = NonNullable<Task['labels']>[number];
6
+ type TaskProject = NonNullable<Task['projects']>[number];
7
+
8
+ function isObject(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === 'object' && value !== null;
10
+ }
11
+
12
+ function normalizeTaskUser(
13
+ assignee: (TaskAssignee & { user: User }) | TaskUser | null | undefined
14
+ ): (TaskUser & { user_id: string }) | null {
15
+ if (!assignee) {
16
+ return null;
17
+ }
18
+
19
+ const expandedUser =
20
+ 'user' in assignee && assignee.user && typeof assignee.user === 'object'
21
+ ? assignee.user
22
+ : null;
23
+
24
+ const effectiveUserId =
25
+ expandedUser?.id ??
26
+ ('user_id' in assignee && typeof assignee.user_id === 'string'
27
+ ? assignee.user_id
28
+ : undefined) ??
29
+ ('id' in assignee && typeof assignee.id === 'string'
30
+ ? assignee.id
31
+ : undefined);
32
+
33
+ if (!effectiveUserId) {
34
+ return null;
35
+ }
36
+
37
+ const displayName =
38
+ expandedUser?.display_name ??
39
+ ('display_name' in assignee && typeof assignee.display_name === 'string'
40
+ ? assignee.display_name
41
+ : undefined);
42
+ const email =
43
+ expandedUser?.email ??
44
+ ('email' in assignee && typeof assignee.email === 'string'
45
+ ? assignee.email
46
+ : undefined);
47
+ const avatarUrl =
48
+ expandedUser?.avatar_url ??
49
+ ('avatar_url' in assignee && typeof assignee.avatar_url === 'string'
50
+ ? assignee.avatar_url
51
+ : undefined);
52
+ const handle =
53
+ expandedUser?.handle ??
54
+ ('handle' in assignee && typeof assignee.handle === 'string'
55
+ ? assignee.handle
56
+ : undefined);
57
+
58
+ return {
59
+ id: effectiveUserId,
60
+ ...(displayName ? { display_name: displayName } : {}),
61
+ ...(email ? { email } : {}),
62
+ ...(avatarUrl ? { avatar_url: avatarUrl } : {}),
63
+ ...(handle ? { handle } : {}),
64
+ user_id: effectiveUserId,
65
+ };
66
+ }
67
+
68
+ export function transformAssignees(
69
+ assignees:
70
+ | Array<(TaskAssignee & { user: User }) | TaskUser>
71
+ | null
72
+ | undefined
73
+ ): (TaskUser & { user_id: string })[] {
74
+ return (
75
+ assignees
76
+ ?.map((assignee) => normalizeTaskUser(assignee))
77
+ .filter((user): user is TaskUser & { user_id: string } =>
78
+ Boolean(user?.id)
79
+ )
80
+ .filter(
81
+ (user, index: number, self) =>
82
+ self.findIndex((u) => u.id === user.id) === index
83
+ ) || []
84
+ );
85
+ }
86
+
87
+ function normalizeLabels(labels: unknown): TaskLabel[] {
88
+ if (!Array.isArray(labels)) {
89
+ return [];
90
+ }
91
+
92
+ return labels
93
+ .map((entry): TaskLabel | null => {
94
+ if (!isObject(entry)) {
95
+ return null;
96
+ }
97
+
98
+ if ('label' in entry && isObject(entry.label) && 'id' in entry.label) {
99
+ return entry.label as TaskLabel;
100
+ }
101
+
102
+ if ('id' in entry) {
103
+ return entry as TaskLabel;
104
+ }
105
+
106
+ return null;
107
+ })
108
+ .filter((label): label is TaskLabel => Boolean(label?.id));
109
+ }
110
+
111
+ function normalizeProjects(projects: unknown): TaskProject[] {
112
+ if (!Array.isArray(projects)) {
113
+ return [];
114
+ }
115
+
116
+ return projects
117
+ .map((entry): TaskProject | null => {
118
+ if (!isObject(entry)) {
119
+ return null;
120
+ }
121
+
122
+ if (
123
+ 'project' in entry &&
124
+ isObject(entry.project) &&
125
+ 'id' in entry.project
126
+ ) {
127
+ return entry.project as TaskProject;
128
+ }
129
+
130
+ if ('id' in entry) {
131
+ return entry as TaskProject;
132
+ }
133
+
134
+ return null;
135
+ })
136
+ .filter((project): project is TaskProject => Boolean(project?.id));
137
+ }
138
+
139
+ export function transformTaskRecord(task: any): Task {
140
+ const normalizedLabels = normalizeLabels(task.labels);
141
+ const normalizedProjects = normalizeProjects(task.projects);
142
+
143
+ return {
144
+ ...task,
145
+ assignees: transformAssignees(task.assignees),
146
+ labels: normalizedLabels,
147
+ projects: normalizedProjects,
148
+ } as Task;
149
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Task date/time helpers that respect a user-configured timezone.
3
+ * Used when creating or updating task start_date/end_date so stored UTC
4
+ * corresponds to the intended local date and time in the user's timezone.
5
+ */
6
+
7
+ import dayjs from 'dayjs';
8
+ import timezone from 'dayjs/plugin/timezone';
9
+ import utc from 'dayjs/plugin/utc';
10
+ import { resolveAutoTimezone } from './timezone';
11
+
12
+ dayjs.extend(utc);
13
+ dayjs.extend(timezone);
14
+
15
+ /**
16
+ * Resolves timezone for task dates: 'auto' → browser, otherwise validated IANA or UTC.
17
+ */
18
+ export function resolveTaskTimezone(timezoneSetting?: string | null): string {
19
+ return resolveAutoTimezone(timezoneSetting);
20
+ }
21
+
22
+ /**
23
+ * Builds a Date representing the given local date/time in the specified timezone.
24
+ * Use this when the user selects a date and time in the UI (e.g. picker) so the
25
+ * resulting instant is correct for that timezone.
26
+ *
27
+ * @param year - Full year
28
+ * @param month - Month 1–12 (calendar month)
29
+ * @param day - Day of month
30
+ * @param hour - Hour 0–23
31
+ * @param minute - Minute 0–59
32
+ * @param tz - IANA timezone (e.g. 'America/New_York') or 'auto' for browser
33
+ */
34
+ export function buildDateInTimezone(
35
+ year: number,
36
+ month: number,
37
+ day: number,
38
+ hour: number,
39
+ minute: number,
40
+ tz: string
41
+ ): Date {
42
+ const resolved = resolveTaskTimezone(tz);
43
+ const s = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
44
+ return dayjs.tz(s, resolved).toDate();
45
+ }
46
+
47
+ /**
48
+ * Returns date parts (year, month, day, hour, minute) of the given instant
49
+ * in the specified timezone. Useful for displaying a UTC date in user TZ
50
+ * or for re-building the same instant from picker date parts.
51
+ */
52
+ export function getDatePartsInTimezone(
53
+ date: Date,
54
+ tz: string
55
+ ): { year: number; month: number; day: number; hour: number; minute: number } {
56
+ const resolved = resolveTaskTimezone(tz);
57
+ const d = dayjs(date).tz(resolved);
58
+ return {
59
+ year: d.year(),
60
+ month: d.month() + 1,
61
+ day: d.date(),
62
+ hour: d.hour(),
63
+ minute: d.minute(),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Converts a Date to UTC ISO string, treating the date's local calendar/time
69
+ * as being in the given timezone. Use when you have a Date built from local
70
+ * input (e.g. picker in browser local) and want to store "this date/time in
71
+ * user's configured timezone" as UTC.
72
+ *
73
+ * @param date - Date (typically from a picker; its getFullYear/getMonth/etc. are in browser local)
74
+ * @param tz - User/workspace timezone
75
+ * @returns ISO string in UTC for storage
76
+ */
77
+ export function dateInTimezoneToUTCISO(date: Date, tz: string): string {
78
+ const resolved = resolveTaskTimezone(tz);
79
+ const y = date.getFullYear();
80
+ const m = date.getMonth() + 1;
81
+ const d = date.getDate();
82
+ const h = date.getHours();
83
+ const min = date.getMinutes();
84
+ const s = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')} ${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:00`;
85
+ return dayjs.tz(s, resolved).toISOString();
86
+ }
87
+
88
+ /**
89
+ * Formats a UTC instant (Date or ISO string) in the given timezone for display.
90
+ */
91
+ export function formatInTimezone(
92
+ date: Date | string,
93
+ tz: string,
94
+ formatStr: string
95
+ ): string {
96
+ const resolved = resolveTaskTimezone(tz);
97
+ const d = dayjs(date).tz(resolved);
98
+ return d.format(formatStr);
99
+ }
100
+
101
+ const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
102
+ const HAS_UTC_OFFSET = /[Zz+-]\d{2}:?\d{2}$|[Zz]$/;
103
+
104
+ /**
105
+ * Parses a date string from the AI (e.g. "2025-02-23" or "2025-02-23T15:00:00")
106
+ * and returns UTC ISO string. When the string has no timezone (date-only or
107
+ * naive datetime), interprets it in the user's timezone.
108
+ *
109
+ * @param dateStr - ISO date or date-only YYYY-MM-DD
110
+ * @param tz - User/workspace IANA timezone
111
+ * @param endOfDay - If true and dateStr is date-only, use 23:59:59.999 in tz; else 00:00:00
112
+ */
113
+ export function parseTaskDateToUTCISO(
114
+ dateStr: string,
115
+ tz: string,
116
+ endOfDay: boolean
117
+ ): string {
118
+ const trimmed = dateStr.trim();
119
+ if (HAS_UTC_OFFSET.test(trimmed)) {
120
+ return dayjs.utc(trimmed).toISOString();
121
+ }
122
+ const resolved = resolveTaskTimezone(tz);
123
+ if (DATE_ONLY_REGEX.test(trimmed)) {
124
+ const d = dayjs.tz(trimmed, resolved);
125
+ const anchored = endOfDay ? d.endOf('day') : d.startOf('day');
126
+ return anchored.toISOString();
127
+ }
128
+ const d = dayjs.tz(trimmed, resolved);
129
+ if (!d.isValid()) {
130
+ return dayjs.utc(trimmed).toISOString();
131
+ }
132
+ return d.toISOString();
133
+ }