Skip to content

Commit 93e007a

Browse files
feat: Add uncompletable task support with isUncompletable field (#418)
Co-authored-by: Claude <[email protected]>
1 parent 26a7da8 commit 93e007a

File tree

7 files changed

+467
-3
lines changed

7 files changed

+467
-3
lines changed

src/test-utils/test-defaults.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export const DEFAULT_TASK: Task = {
9595
noteCount: DEFAULT_NOTE_COUNT,
9696
dayOrder: DEFAULT_ORDER,
9797
isCollapsed: DEFAULT_IS_COLLAPSED,
98+
isUncompletable: false,
9899
url: DEFAULT_TASK_URL,
99100
}
100101

@@ -127,6 +128,7 @@ export const TASK_WITH_OPTIONALS_AS_NULL: Task = {
127128
description: DEFAULT_TASK_DESCRIPTION,
128129
dayOrder: DEFAULT_ORDER,
129130
isCollapsed: DEFAULT_IS_COLLAPSED,
131+
isUncompletable: false,
130132
noteCount: DEFAULT_NOTE_COUNT,
131133
url: DEFAULT_TASK_URL,
132134
}

src/todoist-api.tasks.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,81 @@ describe('TodoistApi task endpoints', () => {
5555

5656
expect(task).toEqual(DEFAULT_TASK)
5757
})
58+
59+
test('adds uncompletable prefix when isUncompletable is true', async () => {
60+
const expectedTask = {
61+
...DEFAULT_TASK,
62+
content: '* This is an uncompletable task',
63+
isUncompletable: true,
64+
}
65+
66+
server.use(
67+
http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, async ({ request }) => {
68+
const body = (await request.json()) as any
69+
expect(body.content).toBe('* This is an uncompletable task')
70+
return HttpResponse.json(expectedTask, { status: 200 })
71+
}),
72+
)
73+
const api = getTarget()
74+
75+
const task = await api.addTask({
76+
content: 'This is an uncompletable task',
77+
isUncompletable: true,
78+
})
79+
80+
expect(task.content).toBe('* This is an uncompletable task')
81+
expect(task.isUncompletable).toBe(true)
82+
})
83+
84+
test('preserves existing prefix when isUncompletable is false', async () => {
85+
const expectedTask = {
86+
...DEFAULT_TASK,
87+
content: '* Already has prefix',
88+
isUncompletable: true,
89+
}
90+
91+
server.use(
92+
http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, async ({ request }) => {
93+
const body = (await request.json()) as any
94+
expect(body.content).toBe('* Already has prefix')
95+
return HttpResponse.json(expectedTask, { status: 200 })
96+
}),
97+
)
98+
const api = getTarget()
99+
100+
const task = await api.addTask({
101+
content: '* Already has prefix',
102+
isUncompletable: false,
103+
})
104+
105+
expect(task.content).toBe('* Already has prefix')
106+
expect(task.isUncompletable).toBe(true)
107+
})
108+
109+
test('does not add prefix when isUncompletable is false', async () => {
110+
const expectedTask = {
111+
...DEFAULT_TASK,
112+
content: 'Regular completable task',
113+
isUncompletable: false,
114+
}
115+
116+
server.use(
117+
http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, async ({ request }) => {
118+
const body = (await request.json()) as any
119+
expect(body.content).toBe('Regular completable task')
120+
return HttpResponse.json(expectedTask, { status: 200 })
121+
}),
122+
)
123+
const api = getTarget()
124+
125+
const task = await api.addTask({
126+
content: 'Regular completable task',
127+
isUncompletable: false,
128+
})
129+
130+
expect(task.content).toBe('Regular completable task')
131+
expect(task.isUncompletable).toBe(false)
132+
})
58133
})
59134

60135
describe('updateTask', () => {
@@ -81,6 +156,55 @@ describe('TodoistApi task endpoints', () => {
81156

82157
expect(response).toEqual(returnedTask)
83158
})
159+
160+
test('processes content with isUncompletable when both are provided', async () => {
161+
const returnedTask = {
162+
...DEFAULT_TASK,
163+
content: '* Updated uncompletable task',
164+
isUncompletable: true,
165+
url: getTaskUrl(DEFAULT_TASK_ID, '* Updated uncompletable task'),
166+
}
167+
168+
server.use(
169+
http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}/123`, async ({ request }) => {
170+
const body = (await request.json()) as any
171+
expect(body.content).toBe('* Updated uncompletable task')
172+
return HttpResponse.json(returnedTask, { status: 200 })
173+
}),
174+
)
175+
const api = getTarget()
176+
177+
const response = await api.updateTask('123', {
178+
content: 'Updated uncompletable task',
179+
isUncompletable: true,
180+
})
181+
182+
expect(response.content).toBe('* Updated uncompletable task')
183+
expect(response.isUncompletable).toBe(true)
184+
})
185+
186+
test('does not process content when only isUncompletable is provided', async () => {
187+
const returnedTask = {
188+
...DEFAULT_TASK,
189+
isUncompletable: false,
190+
}
191+
192+
server.use(
193+
http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}/123`, async ({ request }) => {
194+
const body = (await request.json()) as any
195+
expect(body.content).toBeUndefined()
196+
expect(body.is_uncompletable).toBe(false) // Note: snake_case conversion
197+
return HttpResponse.json(returnedTask, { status: 200 })
198+
}),
199+
)
200+
const api = getTarget()
201+
202+
const response = await api.updateTask('123', {
203+
isUncompletable: false,
204+
})
205+
206+
expect(response.isUncompletable).toBe(false)
207+
})
84208
})
85209

86210
describe('closeTask', () => {
@@ -152,6 +276,56 @@ describe('TodoistApi task endpoints', () => {
152276
const task = await api.quickAddTask(DEFAULT_QUICK_ADD_ARGS)
153277
expect(task).toEqual(DEFAULT_TASK)
154278
})
279+
280+
test('adds uncompletable prefix when isUncompletable is true', async () => {
281+
const expectedTask = {
282+
...DEFAULT_TASK,
283+
content: '* Quick uncompletable task',
284+
isUncompletable: true,
285+
}
286+
287+
server.use(
288+
http.post(`${getSyncBaseUri()}${ENDPOINT_SYNC_QUICK_ADD}`, async ({ request }) => {
289+
const body = (await request.json()) as any
290+
expect(body.text).toBe('* Quick uncompletable task')
291+
return HttpResponse.json(expectedTask, { status: 200 })
292+
}),
293+
)
294+
const api = getTarget()
295+
296+
const task = await api.quickAddTask({
297+
text: 'Quick uncompletable task',
298+
isUncompletable: true,
299+
})
300+
301+
expect(task.content).toBe('* Quick uncompletable task')
302+
expect(task.isUncompletable).toBe(true)
303+
})
304+
305+
test('preserves existing prefix even when isUncompletable is false', async () => {
306+
const expectedTask = {
307+
...DEFAULT_TASK,
308+
content: '* Already prefixed quick task',
309+
isUncompletable: true,
310+
}
311+
312+
server.use(
313+
http.post(`${getSyncBaseUri()}${ENDPOINT_SYNC_QUICK_ADD}`, async ({ request }) => {
314+
const body = (await request.json()) as any
315+
expect(body.text).toBe('* Already prefixed quick task')
316+
return HttpResponse.json(expectedTask, { status: 200 })
317+
}),
318+
)
319+
const api = getTarget()
320+
321+
const task = await api.quickAddTask({
322+
text: '* Already prefixed quick task',
323+
isUncompletable: false,
324+
})
325+
326+
expect(task.content).toBe('* Already prefixed quick task')
327+
expect(task.isUncompletable).toBe(true)
328+
})
155329
})
156330

157331
describe('getTask', () => {

src/todoist-api.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ import {
133133
import { formatDateToYYYYMMDD } from './utils/url-helpers'
134134
import { uploadMultipartFile } from './utils/multipart-upload'
135135
import { normalizeObjectTypeForApi, denormalizeObjectTypeFromApi } from './utils/activity-helpers'
136+
import { processTaskContent } from './utils/uncompletable-helpers'
136137
import { z } from 'zod'
137138

138139
import { v4 as uuidv4 } from 'uuid'
@@ -395,13 +396,19 @@ export class TodoistApi {
395396
* @returns A promise that resolves to the created task.
396397
*/
397398
async addTask(args: AddTaskArgs, requestId?: string): Promise<Task> {
399+
// Process content based on isUncompletable flag
400+
const processedArgs = {
401+
...args,
402+
content: processTaskContent(args.content, args.isUncompletable),
403+
}
404+
398405
const response = await request<Task>({
399406
httpMethod: 'POST',
400407
baseUri: this.syncApiBase,
401408
relativePath: ENDPOINT_REST_TASKS,
402409
apiToken: this.authToken,
403410
customFetch: this.customFetch,
404-
payload: args,
411+
payload: processedArgs,
405412
requestId: requestId,
406413
})
407414

@@ -415,13 +422,19 @@ export class TodoistApi {
415422
* @returns A promise that resolves to the created task.
416423
*/
417424
async quickAddTask(args: QuickAddTaskArgs): Promise<Task> {
425+
// Process text based on isUncompletable flag
426+
const processedArgs = {
427+
...args,
428+
text: processTaskContent(args.text, args.isUncompletable),
429+
}
430+
418431
const response = await request<Task>({
419432
httpMethod: 'POST',
420433
baseUri: this.syncApiBase,
421434
relativePath: ENDPOINT_SYNC_QUICK_ADD,
422435
apiToken: this.authToken,
423436
customFetch: this.customFetch,
424-
payload: args,
437+
payload: processedArgs,
425438
})
426439

427440
return validateTask(response.data)
@@ -437,13 +450,20 @@ export class TodoistApi {
437450
*/
438451
async updateTask(id: string, args: UpdateTaskArgs, requestId?: string): Promise<Task> {
439452
z.string().parse(id)
453+
454+
// Process content if both content and isUncompletable are provided
455+
const processedArgs =
456+
args.content && args.isUncompletable !== undefined
457+
? { ...args, content: processTaskContent(args.content, args.isUncompletable) }
458+
: args
459+
440460
const response = await request<Task>({
441461
httpMethod: 'POST',
442462
baseUri: this.syncApiBase,
443463
relativePath: generatePath(ENDPOINT_REST_TASKS, id),
444464
apiToken: this.authToken,
445465
customFetch: this.customFetch,
446-
payload: args,
466+
payload: processedArgs,
447467
requestId: requestId,
448468
})
449469

src/types/entities.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod'
22
import { getProjectUrl, getTaskUrl, getSectionUrl } from '../utils/url-helpers'
3+
import { hasUncompletablePrefix } from '../utils/uncompletable-helpers'
34

45
export const DueDateSchema = z
56
.object({
@@ -63,10 +64,14 @@ export const TaskSchema = z
6364
noteCount: z.number().int(),
6465
dayOrder: z.number().int(),
6566
isCollapsed: z.boolean(),
67+
isUncompletable: z.boolean().default(false),
6668
})
6769
.transform((data) => {
70+
// Auto-detect uncompletable status from content prefix
71+
const isUncompletable = hasUncompletablePrefix(data.content)
6872
return {
6973
...data,
74+
isUncompletable,
7075
url: getTaskUrl(data.id, data.content),
7176
}
7277
})

src/types/requests.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type AddTaskArgs = {
3232
dueLang?: string
3333
deadlineLang?: string
3434
deadlineDate?: string
35+
isUncompletable?: boolean
3536
} & RequireOneOrNone<{
3637
dueDate?: string
3738
dueDatetime?: string
@@ -141,6 +142,7 @@ export type UpdateTaskArgs = {
141142
assigneeId?: string | null
142143
deadlineDate?: string | null
143144
deadlineLang?: string | null
145+
isUncompletable?: boolean
144146
} & RequireOneOrNone<{
145147
dueDate?: string
146148
dueDatetime?: string
@@ -160,6 +162,7 @@ export type QuickAddTaskArgs = {
160162
reminder?: string
161163
autoReminder?: boolean
162164
meta?: boolean
165+
isUncompletable?: boolean
163166
}
164167

165168
/**

0 commit comments

Comments
 (0)