@tuturuuu/ui 0.6.1 → 0.7.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 (51) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -3
  3. package/biome.json +1 -1
  4. package/package.json +8 -8
  5. package/src/components/ui/calendar-app/components/calendar-connections.tsx +17 -13
  6. package/src/components/ui/calendar-app/components/connected-accounts-dialog.tsx +2 -5
  7. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +2 -5
  8. package/src/components/ui/calendar.test.tsx +24 -0
  9. package/src/components/ui/calendar.tsx +1 -0
  10. package/src/components/ui/date-time-picker.tsx +352 -234
  11. package/src/components/ui/finance/categories-tags-tabs.tsx +23 -1
  12. package/src/components/ui/finance/command/finance-command-actions.test.tsx +48 -0
  13. package/src/components/ui/finance/command/finance-command-actions.tsx +200 -0
  14. package/src/components/ui/finance/command/finance-command-provider.test.tsx +151 -0
  15. package/src/components/ui/finance/command/finance-command-provider.tsx +250 -0
  16. package/src/components/ui/finance/command/finance-command-results.tsx +262 -0
  17. package/src/components/ui/finance/invoices/pending-invoices-table.tsx +22 -9
  18. package/src/components/ui/finance/shared/quick-actions.tsx +39 -90
  19. package/src/components/ui/finance/tags/tag-manager.tsx +24 -5
  20. package/src/components/ui/finance/transactions/form-basic-tab.tsx +33 -49
  21. package/src/components/ui/finance/transactions/form-types.ts +3 -0
  22. package/src/components/ui/finance/transactions/form.test.tsx +105 -22
  23. package/src/components/ui/finance/transactions/form.tsx +116 -20
  24. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +13 -6
  25. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +25 -1
  26. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +16 -3
  27. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +3 -0
  28. package/src/components/ui/finance/transactions/transactionId/transaction-details-page.tsx +3 -0
  29. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +6 -0
  30. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +20 -2
  31. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -0
  32. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +7 -2
  33. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +7 -2
  34. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +38 -1
  35. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +5 -0
  36. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +18 -2
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +3 -0
  38. package/src/components/ui/finance/wallets/wallets-page.tsx +3 -0
  39. package/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +2 -9
  40. package/src/components/ui/optional-time-picker.tsx +95 -0
  41. package/src/components/ui/quick-command-center.test.tsx +90 -0
  42. package/src/components/ui/quick-command-center.tsx +190 -0
  43. package/src/components/ui/storefront/cart-summary.tsx +18 -27
  44. package/src/components/ui/storefront/hero-panel.tsx +22 -13
  45. package/src/components/ui/storefront/storefront-surface.test.tsx +8 -4
  46. package/src/components/ui/storefront/storefront-surface.tsx +84 -41
  47. package/src/components/ui/storefront/types.ts +2 -0
  48. package/src/components/ui/storefront/utils.ts +21 -0
  49. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.test.ts +171 -0
  50. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +200 -36
  51. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +21 -2
@@ -0,0 +1,171 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const internalApiMocks = vi.hoisted(() => ({
4
+ abortWorkspaceTaskDescriptionChunks: vi.fn(),
5
+ appendWorkspaceTaskDescriptionChunk: vi.fn(),
6
+ beginWorkspaceTaskDescriptionChunks: vi.fn(),
7
+ commitWorkspaceTaskDescriptionChunks: vi.fn(),
8
+ createWorkspaceTaskProject: vi.fn(),
9
+ getWorkspaceTask: vi.fn(),
10
+ getWorkspaceTaskDescription: vi.fn(),
11
+ updateWorkspaceTask: vi.fn(),
12
+ updateWorkspaceTaskDescription: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('@tuturuuu/internal-api/tasks', () => internalApiMocks);
16
+
17
+ import {
18
+ shouldChunkTaskDescriptionPayload,
19
+ updateWorkspaceTaskDescription,
20
+ } from './task-api';
21
+
22
+ describe('task-api description persistence', () => {
23
+ beforeEach(() => {
24
+ vi.resetAllMocks();
25
+ internalApiMocks.abortWorkspaceTaskDescriptionChunks.mockResolvedValue({
26
+ success: true,
27
+ });
28
+ internalApiMocks.appendWorkspaceTaskDescriptionChunk.mockResolvedValue({
29
+ success: true,
30
+ });
31
+ internalApiMocks.beginWorkspaceTaskDescriptionChunks.mockResolvedValue({
32
+ session_id: 'chunk-session-1',
33
+ });
34
+ internalApiMocks.commitWorkspaceTaskDescriptionChunks.mockResolvedValue({
35
+ description: 'persisted',
36
+ description_yjs_state: [1, 2, 3],
37
+ });
38
+ internalApiMocks.updateWorkspaceTaskDescription.mockResolvedValue({
39
+ description: 'small',
40
+ description_yjs_state: [1, 2, 3],
41
+ });
42
+ });
43
+
44
+ it('uses the direct description update for small payloads', async () => {
45
+ const payload = {
46
+ description: JSON.stringify({
47
+ type: 'doc',
48
+ content: [{ type: 'paragraph', content: [{ text: 'Small' }] }],
49
+ }),
50
+ description_yjs_state: [1, 2, 3],
51
+ };
52
+
53
+ await expect(
54
+ updateWorkspaceTaskDescription('ws-1', 'task-1', payload)
55
+ ).resolves.toEqual({
56
+ description: 'small',
57
+ description_yjs_state: [1, 2, 3],
58
+ });
59
+
60
+ expect(
61
+ internalApiMocks.updateWorkspaceTaskDescription
62
+ ).toHaveBeenCalledWith('ws-1', 'task-1', payload);
63
+ expect(
64
+ internalApiMocks.beginWorkspaceTaskDescriptionChunks
65
+ ).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it('uploads large description updates through ordered chunks', async () => {
69
+ const yjsState = Array.from({ length: 220_000 }, (_, index) => index % 256);
70
+ const payload = {
71
+ description: JSON.stringify({
72
+ type: 'doc',
73
+ content: [
74
+ {
75
+ type: 'paragraph',
76
+ content: [{ type: 'text', text: 'Large paste' }],
77
+ },
78
+ ],
79
+ }),
80
+ description_yjs_state: yjsState,
81
+ };
82
+
83
+ expect(shouldChunkTaskDescriptionPayload(payload)).toBe(true);
84
+
85
+ await updateWorkspaceTaskDescription('ws-1', 'task-1', payload);
86
+
87
+ expect(
88
+ internalApiMocks.updateWorkspaceTaskDescription
89
+ ).not.toHaveBeenCalled();
90
+ expect(
91
+ internalApiMocks.beginWorkspaceTaskDescriptionChunks
92
+ ).toHaveBeenCalledWith(
93
+ 'ws-1',
94
+ 'task-1',
95
+ expect.objectContaining({
96
+ description: expect.objectContaining({
97
+ chunk_count: 1,
98
+ }),
99
+ description_yjs_state: expect.objectContaining({
100
+ chunk_count: expect.any(Number),
101
+ }),
102
+ })
103
+ );
104
+
105
+ const appendCalls =
106
+ internalApiMocks.appendWorkspaceTaskDescriptionChunk.mock.calls;
107
+ expect(appendCalls.length).toBeGreaterThan(2);
108
+ expect(appendCalls[0]).toEqual([
109
+ 'ws-1',
110
+ 'task-1',
111
+ expect.objectContaining({
112
+ chunk_index: 0,
113
+ field: 'description',
114
+ session_id: 'chunk-session-1',
115
+ }),
116
+ ]);
117
+ expect(
118
+ appendCalls
119
+ .filter((call) => call[2].field === 'description_yjs_state')
120
+ .map((call) => call[2].chunk_index)
121
+ ).toEqual(
122
+ appendCalls
123
+ .filter((call) => call[2].field === 'description_yjs_state')
124
+ .map((_, index) => index)
125
+ );
126
+ expect(
127
+ internalApiMocks.commitWorkspaceTaskDescriptionChunks
128
+ ).toHaveBeenCalledWith('ws-1', 'task-1', 'chunk-session-1');
129
+ });
130
+
131
+ it('falls back to chunked upload when a direct save hits the proxy body limit', async () => {
132
+ internalApiMocks.updateWorkspaceTaskDescription.mockRejectedValueOnce({
133
+ status: 413,
134
+ });
135
+
136
+ await updateWorkspaceTaskDescription('ws-1', 'task-1', {
137
+ description: 'small enough to try directly',
138
+ description_yjs_state: [1, 2, 3],
139
+ });
140
+
141
+ expect(
142
+ internalApiMocks.beginWorkspaceTaskDescriptionChunks
143
+ ).toHaveBeenCalled();
144
+ expect(
145
+ internalApiMocks.commitWorkspaceTaskDescriptionChunks
146
+ ).toHaveBeenCalled();
147
+ });
148
+
149
+ it('aborts the chunk session when an append fails', async () => {
150
+ internalApiMocks.appendWorkspaceTaskDescriptionChunk.mockRejectedValueOnce(
151
+ new Error('network down')
152
+ );
153
+
154
+ await expect(
155
+ updateWorkspaceTaskDescription('ws-1', 'task-1', {
156
+ description: 'x'.repeat(10),
157
+ description_yjs_state: Array.from(
158
+ { length: 220_000 },
159
+ (_, index) => index % 256
160
+ ),
161
+ })
162
+ ).rejects.toThrow('network down');
163
+
164
+ expect(
165
+ internalApiMocks.abortWorkspaceTaskDescriptionChunks
166
+ ).toHaveBeenCalledWith('ws-1', 'task-1', 'chunk-session-1');
167
+ expect(
168
+ internalApiMocks.commitWorkspaceTaskDescriptionChunks
169
+ ).not.toHaveBeenCalled();
170
+ });
171
+ });
@@ -1,11 +1,27 @@
1
1
  import {
2
+ abortWorkspaceTaskDescriptionChunks,
3
+ appendWorkspaceTaskDescriptionChunk,
4
+ beginWorkspaceTaskDescriptionChunks,
5
+ commitWorkspaceTaskDescriptionChunks,
2
6
  createWorkspaceTaskProject,
3
7
  getWorkspaceTask,
8
+ getWorkspaceTaskDescription,
9
+ updateWorkspaceTaskDescription as updateWorkspaceTaskDescriptionViaApi,
4
10
  updateWorkspaceTask as updateWorkspaceTaskViaApi,
11
+ type WorkspaceTaskDescriptionChunkField,
12
+ type WorkspaceTaskDescriptionChunkFields,
13
+ type WorkspaceTaskDescriptionResponse,
14
+ type WorkspaceTaskDescriptionUpdatePayload,
5
15
  type WorkspaceTaskUpdatePayload,
6
16
  } from '@tuturuuu/internal-api/tasks';
7
17
  import type { WorkspaceTaskLabel } from '../types';
8
18
 
19
+ const TASK_DESCRIPTION_DIRECT_BODY_LIMIT_BYTES = 192 * 1024;
20
+ const TASK_DESCRIPTION_CHUNK_TEXT_LENGTH = 96 * 1024;
21
+ const TASK_DESCRIPTION_TOO_LARGE_MESSAGE =
22
+ 'Description content is too large. Please shorten it or split it into smaller documents.';
23
+ const textEncoder = new TextEncoder();
24
+
9
25
  async function getErrorMessage(
10
26
  response: Response,
11
27
  fallback: string,
@@ -38,59 +54,207 @@ export async function fetchWorkspaceTask(wsId: string, taskId: string) {
38
54
  return getWorkspaceTask(wsId, taskId);
39
55
  }
40
56
 
41
- interface TaskDescriptionResponse {
42
- description: string | null;
43
- description_yjs_state: number[] | null;
44
- }
45
-
46
57
  export async function fetchWorkspaceTaskDescription(
47
58
  wsId: string,
48
59
  taskId: string
49
60
  ) {
50
- const requestPath = `/api/v1/workspaces/${wsId}/tasks/${taskId}/description`;
51
- const response = await fetch(requestPath, {
52
- method: 'GET',
53
- cache: 'no-store',
54
- });
61
+ return getWorkspaceTaskDescription(wsId, taskId);
62
+ }
55
63
 
56
- if (!response.ok) {
57
- throw new Error(
58
- await getErrorMessage(response, 'Failed to fetch task description', {
59
- requestPath,
60
- })
64
+ export async function updateWorkspaceTaskDescription(
65
+ wsId: string,
66
+ taskId: string,
67
+ payload: WorkspaceTaskDescriptionUpdatePayload
68
+ ) {
69
+ if (shouldChunkTaskDescriptionPayload(payload)) {
70
+ return updateWorkspaceTaskDescriptionChunked(wsId, taskId, payload);
71
+ }
72
+
73
+ try {
74
+ return await updateWorkspaceTaskDescriptionViaApi(wsId, taskId, payload);
75
+ } catch (error) {
76
+ if (isPayloadTooLargeError(error)) {
77
+ return updateWorkspaceTaskDescriptionChunked(wsId, taskId, payload);
78
+ }
79
+
80
+ throw normalizeTaskDescriptionError(
81
+ error,
82
+ 'Failed to update task description'
61
83
  );
62
84
  }
85
+ }
63
86
 
64
- return (await response.json()) as TaskDescriptionResponse;
87
+ export function shouldChunkTaskDescriptionPayload(
88
+ payload: WorkspaceTaskDescriptionUpdatePayload
89
+ ) {
90
+ return getJsonByteLength(payload) > TASK_DESCRIPTION_DIRECT_BODY_LIMIT_BYTES;
65
91
  }
66
92
 
67
- export async function updateWorkspaceTaskDescription(
93
+ export async function updateWorkspaceTaskDescriptionChunked(
68
94
  wsId: string,
69
95
  taskId: string,
70
- payload: {
71
- description?: string | null;
72
- description_yjs_state?: number[] | null;
96
+ payload: WorkspaceTaskDescriptionUpdatePayload
97
+ ): Promise<WorkspaceTaskDescriptionResponse> {
98
+ const chunksByField = buildTaskDescriptionChunks(payload);
99
+ const fields = buildTaskDescriptionChunkFields(chunksByField);
100
+
101
+ if (Object.keys(fields).length === 0) {
102
+ return updateWorkspaceTaskDescriptionViaApi(wsId, taskId, payload);
73
103
  }
74
- ) {
75
- const requestPath = `/api/v1/workspaces/${wsId}/tasks/${taskId}/description`;
76
- const response = await fetch(requestPath, {
77
- method: 'PATCH',
78
- headers: {
79
- 'Content-Type': 'application/json',
80
- },
81
- body: JSON.stringify(payload),
82
- cache: 'no-store',
83
- });
84
104
 
85
- if (!response.ok) {
86
- throw new Error(
87
- await getErrorMessage(response, 'Failed to update task description', {
88
- requestPath,
89
- })
105
+ const { session_id: sessionId } = await beginWorkspaceTaskDescriptionChunks(
106
+ wsId,
107
+ taskId,
108
+ fields
109
+ );
110
+
111
+ try {
112
+ for (const field of ['description', 'description_yjs_state'] as const) {
113
+ const chunks = chunksByField[field];
114
+ if (!chunks) continue;
115
+
116
+ for (let index = 0; index < chunks.length; index += 1) {
117
+ await appendWorkspaceTaskDescriptionChunk(wsId, taskId, {
118
+ chunk: chunks[index] ?? '',
119
+ chunk_index: index,
120
+ field,
121
+ session_id: sessionId,
122
+ });
123
+ }
124
+ }
125
+
126
+ return await commitWorkspaceTaskDescriptionChunks(wsId, taskId, sessionId);
127
+ } catch (error) {
128
+ await abortWorkspaceTaskDescriptionChunks(wsId, taskId, sessionId).catch(
129
+ () => undefined
130
+ );
131
+
132
+ throw normalizeTaskDescriptionError(
133
+ error,
134
+ 'Failed to update task description'
90
135
  );
91
136
  }
137
+ }
138
+
139
+ function getJsonByteLength(value: unknown) {
140
+ return textEncoder.encode(JSON.stringify(value)).byteLength;
141
+ }
142
+
143
+ function isPayloadTooLargeError(error: unknown) {
144
+ return (
145
+ typeof error === 'object' &&
146
+ error !== null &&
147
+ 'status' in error &&
148
+ (error as { status?: unknown }).status === 413
149
+ );
150
+ }
151
+
152
+ function normalizeTaskDescriptionError(error: unknown, fallback: string) {
153
+ if (isPayloadTooLargeError(error)) {
154
+ return new Error(TASK_DESCRIPTION_TOO_LARGE_MESSAGE);
155
+ }
156
+
157
+ return error instanceof Error ? error : new Error(fallback);
158
+ }
159
+
160
+ function splitStringIntoChunks(value: string) {
161
+ const chunks: string[] = [];
162
+
163
+ for (
164
+ let index = 0;
165
+ index < value.length;
166
+ index += TASK_DESCRIPTION_CHUNK_TEXT_LENGTH
167
+ ) {
168
+ chunks.push(value.slice(index, index + TASK_DESCRIPTION_CHUNK_TEXT_LENGTH));
169
+ }
170
+
171
+ return chunks.length > 0 ? chunks : [''];
172
+ }
173
+
174
+ function bytesToBase64(bytes: number[]) {
175
+ const byteArray = Uint8Array.from(bytes);
176
+
177
+ if (typeof btoa === 'function') {
178
+ let binary = '';
179
+ const step = 0x8000;
180
+
181
+ for (let index = 0; index < byteArray.length; index += step) {
182
+ binary += String.fromCharCode(...byteArray.subarray(index, index + step));
183
+ }
184
+
185
+ return btoa(binary);
186
+ }
187
+
188
+ const nodeBuffer = (
189
+ globalThis as typeof globalThis & {
190
+ Buffer?: {
191
+ from: (value: Uint8Array) => {
192
+ toString: (encoding: 'base64') => string;
193
+ };
194
+ };
195
+ }
196
+ ).Buffer;
197
+
198
+ if (!nodeBuffer) {
199
+ throw new Error('Unable to encode task description state.');
200
+ }
201
+
202
+ return nodeBuffer.from(byteArray).toString('base64');
203
+ }
204
+
205
+ function buildTaskDescriptionChunks(
206
+ payload: WorkspaceTaskDescriptionUpdatePayload
207
+ ): Partial<Record<WorkspaceTaskDescriptionChunkField, string[] | null>> {
208
+ const chunks: Partial<
209
+ Record<WorkspaceTaskDescriptionChunkField, string[] | null>
210
+ > = {};
211
+
212
+ if ('description' in payload) {
213
+ chunks.description =
214
+ payload.description === null
215
+ ? null
216
+ : splitStringIntoChunks(payload.description ?? '');
217
+ }
218
+
219
+ if ('description_yjs_state' in payload) {
220
+ chunks.description_yjs_state =
221
+ payload.description_yjs_state === null
222
+ ? null
223
+ : splitStringIntoChunks(
224
+ bytesToBase64(payload.description_yjs_state ?? [])
225
+ );
226
+ }
227
+
228
+ return chunks;
229
+ }
230
+
231
+ function buildTaskDescriptionChunkFields(
232
+ chunksByField: Partial<
233
+ Record<WorkspaceTaskDescriptionChunkField, string[] | null>
234
+ >
235
+ ) {
236
+ const fields: WorkspaceTaskDescriptionChunkFields = {};
237
+
238
+ for (const field of ['description', 'description_yjs_state'] as const) {
239
+ const chunks = chunksByField[field];
240
+ if (chunks === undefined) continue;
241
+
242
+ if (chunks === null) {
243
+ fields[field] = {
244
+ chunk_count: 0,
245
+ is_null: true,
246
+ total_length: 0,
247
+ };
248
+ continue;
249
+ }
250
+
251
+ fields[field] = {
252
+ chunk_count: chunks.length,
253
+ total_length: chunks.join('').length,
254
+ };
255
+ }
92
256
 
93
- return (await response.json()) as TaskDescriptionResponse;
257
+ return fields;
94
258
  }
95
259
 
96
260
  export async function createWorkspaceLabel(
@@ -34,6 +34,7 @@ import {
34
34
  updateTaskDescriptionCaches,
35
35
  } from '../utils';
36
36
  import {
37
+ shouldChunkTaskDescriptionPayload,
37
38
  updateWorkspaceTask,
38
39
  updateWorkspaceTaskDescription,
39
40
  } from './task-api';
@@ -945,9 +946,17 @@ export async function handleCreateTask({
945
946
  ];
946
947
  }
947
948
 
949
+ const createDescriptionPayload = {
950
+ description: descriptionString || '',
951
+ description_yjs_state: descriptionYjsState ?? undefined,
952
+ };
953
+ const shouldDeferDescription = shouldChunkTaskDescriptionPayload(
954
+ createDescriptionPayload
955
+ );
956
+
948
957
  const taskData: Partial<Task> = {
949
958
  name: name.trim(),
950
- description: descriptionString || '',
959
+ description: shouldDeferDescription ? '' : descriptionString || '',
951
960
  priority: priority,
952
961
  start_date: startDate ? startDate.toISOString() : undefined,
953
962
  end_date: endDate ? endDate.toISOString() : undefined,
@@ -957,7 +966,9 @@ export async function handleCreateTask({
957
966
  };
958
967
  const newTask = await createTask(wsId, selectedListId, {
959
968
  ...taskData,
960
- description_yjs_state: descriptionYjsState ?? undefined,
969
+ description_yjs_state: shouldDeferDescription
970
+ ? undefined
971
+ : (descriptionYjsState ?? undefined),
961
972
  label_ids: selectedLabels.map((label) => label.id),
962
973
  assignee_ids: desiredAssignees
963
974
  .map((assignee) => assignee.user_id || assignee.id)
@@ -965,6 +976,14 @@ export async function handleCreateTask({
965
976
  project_ids: selectedProjects.map((project) => project.id),
966
977
  });
967
978
 
979
+ if (shouldDeferDescription) {
980
+ await updateWorkspaceTaskDescription(wsId, newTask.id, {
981
+ description: descriptionString,
982
+ description_yjs_state: descriptionYjsState,
983
+ });
984
+ newTask.description = descriptionString ?? undefined;
985
+ }
986
+
968
987
  // Save per-user scheduling settings for the creator (if any were provided)
969
988
  const hasAnySchedulingValue =
970
989
  totalDuration != null ||