@doist/todoist-ai 2.0.1 → 2.1.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 (32) hide show
  1. package/dist/index.d.ts +11 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/tool-helpers.d.ts +2 -0
  4. package/dist/tool-helpers.d.ts.map +1 -1
  5. package/dist/tool-helpers.js +2 -0
  6. package/dist/tool-helpers.test.js +20 -0
  7. package/dist/tools/__tests__/tasks-add-multiple.test.js +114 -0
  8. package/dist/tools/__tests__/tasks-update-one.test.js +90 -0
  9. package/dist/tools/tasks-add-multiple.d.ts +5 -0
  10. package/dist/tools/tasks-add-multiple.d.ts.map +1 -1
  11. package/dist/tools/tasks-add-multiple.js +24 -1
  12. package/dist/tools/tasks-list-by-date.d.ts +1 -0
  13. package/dist/tools/tasks-list-by-date.d.ts.map +1 -1
  14. package/dist/tools/tasks-list-completed.d.ts +1 -0
  15. package/dist/tools/tasks-list-completed.d.ts.map +1 -1
  16. package/dist/tools/tasks-list-for-container.d.ts +1 -0
  17. package/dist/tools/tasks-list-for-container.d.ts.map +1 -1
  18. package/dist/tools/tasks-search.d.ts +1 -0
  19. package/dist/tools/tasks-search.d.ts.map +1 -1
  20. package/dist/tools/tasks-update-one.d.ts +2 -0
  21. package/dist/tools/tasks-update-one.d.ts.map +1 -1
  22. package/dist/tools/tasks-update-one.js +24 -1
  23. package/dist/tools/test-helpers.d.ts +1 -0
  24. package/dist/tools/test-helpers.d.ts.map +1 -1
  25. package/dist/tools/test-helpers.js +1 -0
  26. package/dist/utils/duration-parser.d.ts +36 -0
  27. package/dist/utils/duration-parser.d.ts.map +1 -0
  28. package/dist/utils/duration-parser.js +96 -0
  29. package/dist/utils/duration-parser.test.d.ts +2 -0
  30. package/dist/utils/duration-parser.test.d.ts.map +1 -0
  31. package/dist/utils/duration-parser.test.js +147 -0
  32. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -136,6 +136,7 @@ declare const tools: {
136
136
  sectionId: string | null;
137
137
  parentId: string | null;
138
138
  labels: string[];
139
+ duration: string | null;
139
140
  }[];
140
141
  nextCursor: string | null;
141
142
  }>;
@@ -176,6 +177,7 @@ declare const tools: {
176
177
  sectionId: string | null;
177
178
  parentId: string | null;
178
179
  labels: string[];
180
+ duration: string | null;
179
181
  }[];
180
182
  nextCursor: string | null;
181
183
  }>;
@@ -206,6 +208,7 @@ declare const tools: {
206
208
  sectionId: string | null;
207
209
  parentId: string | null;
208
210
  labels: string[];
211
+ duration: string | null;
209
212
  }[];
210
213
  nextCursor: string | null;
211
214
  }>;
@@ -247,6 +250,7 @@ declare const tools: {
247
250
  sectionId: string | null;
248
251
  parentId: string | null;
249
252
  labels: string[];
253
+ duration: string | null;
250
254
  }[];
251
255
  nextCursor: string | null;
252
256
  }>;
@@ -263,16 +267,19 @@ declare const tools: {
263
267
  description: import("zod").ZodOptional<import("zod").ZodString>;
264
268
  priority: import("zod").ZodOptional<import("zod").ZodNumber>;
265
269
  dueString: import("zod").ZodOptional<import("zod").ZodString>;
270
+ duration: import("zod").ZodOptional<import("zod").ZodString>;
266
271
  }, "strip", import("zod").ZodTypeAny, {
267
272
  content: string;
268
273
  description?: string | undefined;
269
274
  priority?: number | undefined;
270
275
  dueString?: string | undefined;
276
+ duration?: string | undefined;
271
277
  }, {
272
278
  content: string;
273
279
  description?: string | undefined;
274
280
  priority?: number | undefined;
275
281
  dueString?: string | undefined;
282
+ duration?: string | undefined;
276
283
  }>, "many">;
277
284
  };
278
285
  execute(args: {
@@ -281,6 +288,7 @@ declare const tools: {
281
288
  description?: string | undefined;
282
289
  priority?: number | undefined;
283
290
  dueString?: string | undefined;
291
+ duration?: string | undefined;
284
292
  }[];
285
293
  parentId?: string | undefined;
286
294
  projectId?: string | undefined;
@@ -296,6 +304,7 @@ declare const tools: {
296
304
  sectionId: string | null;
297
305
  parentId: string | null;
298
306
  labels: string[];
307
+ duration: string | null;
299
308
  }[]>;
300
309
  };
301
310
  tasksUpdateOne: {
@@ -310,6 +319,7 @@ declare const tools: {
310
319
  parentId: import("zod").ZodOptional<import("zod").ZodString>;
311
320
  priority: import("zod").ZodOptional<import("zod").ZodNumber>;
312
321
  dueString: import("zod").ZodOptional<import("zod").ZodString>;
322
+ duration: import("zod").ZodOptional<import("zod").ZodString>;
313
323
  };
314
324
  execute(args: {
315
325
  id: string;
@@ -320,6 +330,7 @@ declare const tools: {
320
330
  sectionId?: string | undefined;
321
331
  priority?: number | undefined;
322
332
  dueString?: string | undefined;
333
+ duration?: string | undefined;
323
334
  }, client: import("@doist/todoist-api-typescript").TodoistApi): Promise<import("@doist/todoist-api-typescript").Task | undefined>;
324
335
  };
325
336
  tasksOrganizeMultiple: {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAE9C,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAE3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAE3D,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAA;AAChE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAA;AAC1E,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAA;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAA;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAA;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAE5D,QAAA,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAeV,CAAA;AAED,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,CAAA;AAE9B,OAAO,EACH,YAAY,EACZ,cAAc,EACd,SAAS,EACT,cAAc,EACd,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EAClB,qBAAqB,EACrB,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,qBAAqB,EACrB,QAAQ,GACX,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAE9C,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAE3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAE3D,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAA;AAChE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAA;AAC1E,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAA;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAA;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAA;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAE5D,QAAA,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAeV,CAAA;AAED,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,CAAA;AAE9B,OAAO,EACH,YAAY,EACZ,cAAc,EACd,SAAS,EACT,cAAc,EACd,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EAClB,qBAAqB,EACrB,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,qBAAqB,EACrB,QAAQ,GACX,CAAA"}
@@ -28,6 +28,7 @@ declare function mapTask(task: Task): {
28
28
  sectionId: string | null;
29
29
  parentId: string | null;
30
30
  labels: string[];
31
+ duration: string | null;
31
32
  };
32
33
  /**
33
34
  * Map a single Todoist project to a more structured format, for LLM consumption.
@@ -61,6 +62,7 @@ declare function getTasksByFilter({ client, query, limit, cursor, }: {
61
62
  sectionId: string | null;
62
63
  parentId: string | null;
63
64
  labels: string[];
65
+ duration: string | null;
64
66
  }[];
65
67
  nextCursor: string | null;
66
68
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"tool-helpers.d.ts","sourceRoot":"","sources":["../src/tool-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,IAAI,EACT,KAAK,UAAU,EACf,KAAK,gBAAgB,EAExB,MAAM,+BAA+B,CAAA;AAGtC,MAAM,MAAM,OAAO,GAAG,eAAe,GAAG,gBAAgB,CAAA;AAExD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,eAAe,CAE9E;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAEhF;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAC9B,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAClB,YAAY,CAsBd;AAED;;;;GAIG;AACH,iBAAS,OAAO,CAAC,IAAI,EAAE,IAAI;;;;;;;;;;;EAa1B;AAED;;;;GAIG;AACH,iBAAS,UAAU,CAAC,OAAO,EAAE,OAAO;;;;;;;;;EAWnC;AAWD,iBAAe,gBAAgB,CAAC,EAC5B,MAAM,EACN,KAAK,EACL,KAAK,EACL,MAAM,GACT,EAAE;IACC,MAAM,EAAE,UAAU,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IACzB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7B;;;;;;;;;;;;;;GAyBA;AAED,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,UAAU,EAAE,CAAA"}
1
+ {"version":3,"file":"tool-helpers.d.ts","sourceRoot":"","sources":["../src/tool-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,IAAI,EACT,KAAK,UAAU,EACf,KAAK,gBAAgB,EAExB,MAAM,+BAA+B,CAAA;AAItC,MAAM,MAAM,OAAO,GAAG,eAAe,GAAG,gBAAgB,CAAA;AAExD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,eAAe,CAE9E;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAEhF;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAC9B,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAClB,YAAY,CAsBd;AAED;;;;GAIG;AACH,iBAAS,OAAO,CAAC,IAAI,EAAE,IAAI;;;;;;;;;;;;EAc1B;AAED;;;;GAIG;AACH,iBAAS,UAAU,CAAC,OAAO,EAAE,OAAO;;;;;;;;;EAWnC;AAWD,iBAAe,gBAAgB,CAAC,EAC5B,MAAM,EACN,KAAK,EACL,KAAK,EACL,MAAM,GACT,EAAE;IACC,MAAM,EAAE,UAAU,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IACzB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7B;;;;;;;;;;;;;;;GAyBA;AAED,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,UAAU,EAAE,CAAA"}
@@ -1,5 +1,6 @@
1
1
  import { getSanitizedContent, } from '@doist/todoist-api-typescript';
2
2
  import z from 'zod';
3
+ import { formatDuration } from './utils/duration-parser.js';
3
4
  export function isPersonalProject(project) {
4
5
  return 'inboxProject' in project;
5
6
  }
@@ -51,6 +52,7 @@ function mapTask(task) {
51
52
  sectionId: task.sectionId,
52
53
  parentId: task.parentId,
53
54
  labels: task.labels,
55
+ duration: task.duration ? formatDuration(task.duration.amount) : null,
54
56
  };
55
57
  }
56
58
  /**
@@ -30,6 +30,7 @@ describe('shared utilities', () => {
30
30
  sectionId: null,
31
31
  parentId: null,
32
32
  labels: ['work'],
33
+ duration: null,
33
34
  });
34
35
  });
35
36
  it('should handle recurring tasks', () => {
@@ -52,6 +53,25 @@ describe('shared utilities', () => {
52
53
  };
53
54
  const result = mapTask(mockTask);
54
55
  expect(result.recurring).toBe('every day');
56
+ expect(result.duration).toBe(null);
57
+ });
58
+ it('should handle task with duration', () => {
59
+ const mockTask = {
60
+ id: '789',
61
+ content: 'Task with duration',
62
+ description: '',
63
+ projectId: 'proj-1',
64
+ sectionId: null,
65
+ parentId: null,
66
+ labels: [],
67
+ priority: 1,
68
+ duration: {
69
+ amount: 150,
70
+ unit: 'minute',
71
+ },
72
+ };
73
+ const result = mapTask(mockTask);
74
+ expect(result.duration).toBe('2h30m');
55
75
  });
56
76
  });
57
77
  describe('mapProject', () => {
@@ -129,8 +129,122 @@ describe('tasks-add-multiple tool', () => {
129
129
  }),
130
130
  ]);
131
131
  });
132
+ it('should add tasks with duration', async () => {
133
+ const mockApiResponse1 = createMockTask({
134
+ id: '8485093752',
135
+ content: 'Task with 2 hour duration',
136
+ duration: { amount: 120, unit: 'minute' },
137
+ url: 'https://todoist.com/showTask?id=8485093752',
138
+ addedAt: '2025-08-13T22:09:56.123456Z',
139
+ });
140
+ const mockApiResponse2 = createMockTask({
141
+ id: '8485093753',
142
+ content: 'Task with 45 minute duration',
143
+ duration: { amount: 45, unit: 'minute' },
144
+ url: 'https://todoist.com/showTask?id=8485093753',
145
+ addedAt: '2025-08-13T22:09:57.123456Z',
146
+ });
147
+ mockTodoistApi.addTask
148
+ .mockResolvedValueOnce(mockApiResponse1)
149
+ .mockResolvedValueOnce(mockApiResponse2);
150
+ const result = await tasksAddMultiple.execute({
151
+ projectId: '6cfCcrrCFg2xP94Q',
152
+ tasks: [
153
+ {
154
+ content: 'Task with 2 hour duration',
155
+ duration: '2h',
156
+ },
157
+ {
158
+ content: 'Task with 45 minute duration',
159
+ duration: '45m',
160
+ },
161
+ ],
162
+ }, mockTodoistApi);
163
+ // Verify API was called with parsed duration
164
+ expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(1, {
165
+ content: 'Task with 2 hour duration',
166
+ projectId: '6cfCcrrCFg2xP94Q',
167
+ sectionId: undefined,
168
+ parentId: undefined,
169
+ duration: 120,
170
+ durationUnit: 'minute',
171
+ });
172
+ expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(2, {
173
+ content: 'Task with 45 minute duration',
174
+ projectId: '6cfCcrrCFg2xP94Q',
175
+ sectionId: undefined,
176
+ parentId: undefined,
177
+ duration: 45,
178
+ durationUnit: 'minute',
179
+ });
180
+ // Verify result includes formatted duration
181
+ expect(result).toEqual([
182
+ expect.objectContaining({
183
+ id: '8485093752',
184
+ content: 'Task with 2 hour duration',
185
+ duration: '2h',
186
+ }),
187
+ expect.objectContaining({
188
+ id: '8485093753',
189
+ content: 'Task with 45 minute duration',
190
+ duration: '45m',
191
+ }),
192
+ ]);
193
+ });
194
+ it('should handle various duration formats', async () => {
195
+ const mockApiResponse = createMockTask({
196
+ id: '8485093754',
197
+ content: 'Task with combined duration',
198
+ duration: { amount: 150, unit: 'minute' },
199
+ url: 'https://todoist.com/showTask?id=8485093754',
200
+ addedAt: '2025-08-13T22:09:56.123456Z',
201
+ });
202
+ mockTodoistApi.addTask.mockResolvedValue(mockApiResponse);
203
+ // Test different duration formats
204
+ const testCases = [
205
+ { input: '2h30m', expectedMinutes: 150 },
206
+ { input: '1.5h', expectedMinutes: 90 },
207
+ { input: ' 90m ', expectedMinutes: 90 },
208
+ { input: '2H30M', expectedMinutes: 150 },
209
+ ];
210
+ for (const testCase of testCases) {
211
+ mockTodoistApi.addTask.mockClear();
212
+ await tasksAddMultiple.execute({
213
+ tasks: [
214
+ {
215
+ content: 'Test task',
216
+ duration: testCase.input,
217
+ },
218
+ ],
219
+ }, mockTodoistApi);
220
+ expect(mockTodoistApi.addTask).toHaveBeenCalledWith(expect.objectContaining({
221
+ duration: testCase.expectedMinutes,
222
+ durationUnit: 'minute',
223
+ }));
224
+ }
225
+ });
132
226
  });
133
227
  describe('error handling', () => {
228
+ it('should throw error for invalid duration format', async () => {
229
+ await expect(tasksAddMultiple.execute({
230
+ tasks: [
231
+ {
232
+ content: 'Task with invalid duration',
233
+ duration: 'invalid',
234
+ },
235
+ ],
236
+ }, mockTodoistApi)).rejects.toThrow('Task "Task with invalid duration": Invalid duration format "invalid"');
237
+ });
238
+ it('should throw error for duration exceeding 24 hours', async () => {
239
+ await expect(tasksAddMultiple.execute({
240
+ tasks: [
241
+ {
242
+ content: 'Task with too long duration',
243
+ duration: '25h',
244
+ },
245
+ ],
246
+ }, mockTodoistApi)).rejects.toThrow('Task "Task with too long duration": Invalid duration format "25h": Duration cannot exceed 24 hours (1440 minutes)');
247
+ });
134
248
  it('should propagate API errors', async () => {
135
249
  const apiError = new Error('API Error: Task content is required');
136
250
  mockTodoistApi.addTask.mockRejectedValue(apiError);
@@ -135,8 +135,98 @@ describe('tasks-update-one tool', () => {
135
135
  });
136
136
  expect(result).toEqual(updatedTask);
137
137
  });
138
+ it('should update task duration', async () => {
139
+ const mockApiResponse = createMockTask({
140
+ id: '8485093753',
141
+ content: 'Task with updated duration',
142
+ duration: { amount: 150, unit: 'minute' },
143
+ url: 'https://todoist.com/showTask?id=8485093753',
144
+ addedAt: '2025-08-13T22:09:56.123456Z',
145
+ });
146
+ mockTodoistApi.updateTask.mockResolvedValue(mockApiResponse);
147
+ const result = await tasksUpdateOne.execute({
148
+ id: '8485093753',
149
+ duration: '2h30m',
150
+ }, mockTodoistApi);
151
+ expect(mockTodoistApi.updateTask).toHaveBeenCalledWith('8485093753', {
152
+ duration: 150,
153
+ durationUnit: 'minute',
154
+ });
155
+ expect(result).toEqual(mockApiResponse);
156
+ });
157
+ it('should handle various duration formats', async () => {
158
+ const mockApiResponse = createMockTask({
159
+ id: '8485093754',
160
+ content: 'Test task',
161
+ duration: { amount: 120, unit: 'minute' },
162
+ });
163
+ mockTodoistApi.updateTask.mockResolvedValue(mockApiResponse);
164
+ // Test different duration formats
165
+ const testCases = [
166
+ { input: '2h', expectedMinutes: 120 },
167
+ { input: '90m', expectedMinutes: 90 },
168
+ { input: '1.5h', expectedMinutes: 90 },
169
+ { input: ' 2h 30m ', expectedMinutes: 150 },
170
+ { input: '2H30M', expectedMinutes: 150 },
171
+ ];
172
+ for (const testCase of testCases) {
173
+ mockTodoistApi.updateTask.mockClear();
174
+ await tasksUpdateOne.execute({
175
+ id: '8485093754',
176
+ duration: testCase.input,
177
+ }, mockTodoistApi);
178
+ expect(mockTodoistApi.updateTask).toHaveBeenCalledWith('8485093754', expect.objectContaining({
179
+ duration: testCase.expectedMinutes,
180
+ durationUnit: 'minute',
181
+ }));
182
+ }
183
+ });
184
+ it('should update task with duration and move at once', async () => {
185
+ const movedTask = createMockTask({
186
+ id: '8485093755',
187
+ content: 'Task to move and update',
188
+ projectId: 'new-project-id',
189
+ });
190
+ const updatedTask = createMockTask({
191
+ id: '8485093755',
192
+ content: 'Updated task with duration',
193
+ duration: { amount: 120, unit: 'minute' },
194
+ projectId: 'new-project-id',
195
+ });
196
+ mockTodoistApi.moveTasks.mockResolvedValue([movedTask]);
197
+ mockTodoistApi.updateTask.mockResolvedValue(updatedTask);
198
+ const result = await tasksUpdateOne.execute({
199
+ id: '8485093755',
200
+ content: 'Updated task with duration',
201
+ duration: '2h',
202
+ projectId: 'new-project-id',
203
+ }, mockTodoistApi);
204
+ // Should call moveTasks first
205
+ expect(mockTodoistApi.moveTasks).toHaveBeenCalledWith(['8485093755'], {
206
+ projectId: 'new-project-id',
207
+ });
208
+ // Then call updateTask with duration
209
+ expect(mockTodoistApi.updateTask).toHaveBeenCalledWith('8485093755', {
210
+ content: 'Updated task with duration',
211
+ duration: 120,
212
+ durationUnit: 'minute',
213
+ });
214
+ expect(result).toEqual(updatedTask);
215
+ });
138
216
  });
139
217
  describe('error handling', () => {
218
+ it('should throw error for invalid duration format', async () => {
219
+ await expect(tasksUpdateOne.execute({
220
+ id: '8485093756',
221
+ duration: 'invalid',
222
+ }, mockTodoistApi)).rejects.toThrow('Task 8485093756: Invalid duration format "invalid"');
223
+ });
224
+ it('should throw error for duration exceeding 24 hours', async () => {
225
+ await expect(tasksUpdateOne.execute({
226
+ id: '8485093757',
227
+ duration: '25h',
228
+ }, mockTodoistApi)).rejects.toThrow('Task 8485093757: Invalid duration format "25h": Duration cannot exceed 24 hours (1440 minutes)');
229
+ });
140
230
  it('should throw error when multiple move parameters are provided', async () => {
141
231
  await expect(tasksUpdateOne.execute({ id: '8485093748', projectId: 'new-project', sectionId: 'new-section' }, mockTodoistApi)).rejects.toThrow('Only one of projectId, sectionId, or parentId can be specified at a time. ' +
142
232
  'The Todoist API requires exactly one destination for move operations.');
@@ -11,16 +11,19 @@ declare const tasksAddMultiple: {
11
11
  description: z.ZodOptional<z.ZodString>;
12
12
  priority: z.ZodOptional<z.ZodNumber>;
13
13
  dueString: z.ZodOptional<z.ZodString>;
14
+ duration: z.ZodOptional<z.ZodString>;
14
15
  }, "strip", z.ZodTypeAny, {
15
16
  content: string;
16
17
  description?: string | undefined;
17
18
  priority?: number | undefined;
18
19
  dueString?: string | undefined;
20
+ duration?: string | undefined;
19
21
  }, {
20
22
  content: string;
21
23
  description?: string | undefined;
22
24
  priority?: number | undefined;
23
25
  dueString?: string | undefined;
26
+ duration?: string | undefined;
24
27
  }>, "many">;
25
28
  };
26
29
  execute(args: {
@@ -29,6 +32,7 @@ declare const tasksAddMultiple: {
29
32
  description?: string | undefined;
30
33
  priority?: number | undefined;
31
34
  dueString?: string | undefined;
35
+ duration?: string | undefined;
32
36
  }[];
33
37
  parentId?: string | undefined;
34
38
  projectId?: string | undefined;
@@ -44,6 +48,7 @@ declare const tasksAddMultiple: {
44
48
  sectionId: string | null;
45
49
  parentId: string | null;
46
50
  labels: string[];
51
+ duration: string | null;
47
52
  }[]>;
48
53
  };
49
54
  export { tasksAddMultiple };
@@ -1 +1 @@
1
- {"version":3,"file":"tasks-add-multiple.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-add-multiple.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAkBvB,QAAA,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAaoB,CAAA;AAE1C,OAAO,EAAE,gBAAgB,EAAE,CAAA"}
1
+ {"version":3,"file":"tasks-add-multiple.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-add-multiple.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAyBvB,QAAA,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCoB,CAAA;AAE1C,OAAO,EAAE,gBAAgB,EAAE,CAAA"}
@@ -1,10 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { mapTask } from '../tool-helpers.js';
3
+ import { DurationParseError, parseDuration } from '../utils/duration-parser.js';
3
4
  const TaskSchema = z.object({
4
5
  content: z.string().min(1).describe('The content of the task to create.'),
5
6
  description: z.string().optional().describe('The description of the task.'),
6
7
  priority: z.number().int().min(1).max(4).optional().describe('The priority of the task (1-4).'),
7
8
  dueString: z.string().optional().describe('The due date for the task, in natural language.'),
9
+ duration: z
10
+ .string()
11
+ .optional()
12
+ .describe('The duration of the task. Use format: "2h" (hours), "90m" (minutes), "2h30m" (combined), or "1.5h" (decimal hours). Max 24h.'),
8
13
  });
9
14
  const ArgsSchema = {
10
15
  projectId: z.string().optional().describe('The project ID to add the tasks to.'),
@@ -20,7 +25,25 @@ const tasksAddMultiple = {
20
25
  const { projectId, sectionId, parentId, tasks } = args;
21
26
  const newTasks = [];
22
27
  for (const task of tasks) {
23
- const taskArgs = { ...task, projectId, sectionId, parentId };
28
+ const { duration: durationStr, ...otherTaskArgs } = task;
29
+ let taskArgs = { ...otherTaskArgs, projectId, sectionId, parentId };
30
+ // Parse duration if provided
31
+ if (durationStr) {
32
+ try {
33
+ const { minutes } = parseDuration(durationStr);
34
+ taskArgs = {
35
+ ...taskArgs,
36
+ duration: minutes,
37
+ durationUnit: 'minute',
38
+ };
39
+ }
40
+ catch (error) {
41
+ if (error instanceof DurationParseError) {
42
+ throw new Error(`Task "${task.content}": ${error.message}`);
43
+ }
44
+ throw error;
45
+ }
46
+ }
24
47
  newTasks.push(await client.addTask(taskArgs));
25
48
  }
26
49
  return newTasks.map(mapTask);
@@ -25,6 +25,7 @@ declare const tasksListByDate: {
25
25
  sectionId: string | null;
26
26
  parentId: string | null;
27
27
  labels: string[];
28
+ duration: string | null;
28
29
  }[];
29
30
  nextCursor: string | null;
30
31
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"tasks-list-by-date.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-by-date.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAmCvB,QAAA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BqB,CAAA;AAE1C,OAAO,EAAE,eAAe,EAAE,CAAA"}
1
+ {"version":3,"file":"tasks-list-by-date.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-by-date.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAmCvB,QAAA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BqB,CAAA;AAE1C,OAAO,EAAE,eAAe,EAAE,CAAA"}
@@ -35,6 +35,7 @@ declare const tasksListCompleted: {
35
35
  sectionId: string | null;
36
36
  parentId: string | null;
37
37
  labels: string[];
38
+ duration: string | null;
38
39
  }[];
39
40
  nextCursor: string | null;
40
41
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"tasks-list-completed.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-completed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwCvB,QAAA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAekB,CAAA;AAE1C,OAAO,EAAE,kBAAkB,EAAE,CAAA"}
1
+ {"version":3,"file":"tasks-list-completed.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-completed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwCvB,QAAA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAekB,CAAA;AAE1C,OAAO,EAAE,kBAAkB,EAAE,CAAA"}
@@ -25,6 +25,7 @@ declare const tasksListForContainer: {
25
25
  sectionId: string | null;
26
26
  parentId: string | null;
27
27
  labels: string[];
28
+ duration: string | null;
28
29
  }[];
29
30
  nextCursor: string | null;
30
31
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"tasks-list-for-container.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-for-container.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwBvB,QAAA,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+Be,CAAA;AAE1C,OAAO,EAAE,qBAAqB,EAAE,CAAA"}
1
+ {"version":3,"file":"tasks-list-for-container.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-for-container.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwBvB,QAAA,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+Be,CAAA;AAE1C,OAAO,EAAE,qBAAqB,EAAE,CAAA"}
@@ -23,6 +23,7 @@ declare const tasksSearch: {
23
23
  sectionId: string | null;
24
24
  parentId: string | null;
25
25
  labels: string[];
26
+ duration: string | null;
26
27
  }[];
27
28
  nextCursor: string | null;
28
29
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"tasks-search.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAqBvB,QAAA,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;CAYyB,CAAA;AAE1C,OAAO,EAAE,WAAW,EAAE,CAAA"}
1
+ {"version":3,"file":"tasks-search.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAqBvB,QAAA,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAYyB,CAAA;AAE1C,OAAO,EAAE,WAAW,EAAE,CAAA"}
@@ -11,6 +11,7 @@ declare const tasksUpdateOne: {
11
11
  parentId: z.ZodOptional<z.ZodString>;
12
12
  priority: z.ZodOptional<z.ZodNumber>;
13
13
  dueString: z.ZodOptional<z.ZodString>;
14
+ duration: z.ZodOptional<z.ZodString>;
14
15
  };
15
16
  execute(args: {
16
17
  id: string;
@@ -21,6 +22,7 @@ declare const tasksUpdateOne: {
21
22
  sectionId?: string | undefined;
22
23
  priority?: number | undefined;
23
24
  dueString?: string | undefined;
25
+ duration?: string | undefined;
24
26
  }, client: import("@doist/todoist-api-typescript").TodoistApi): Promise<import("@doist/todoist-api-typescript").Task | undefined>;
25
27
  };
26
28
  export { tasksUpdateOne };
@@ -1 +1 @@
1
- {"version":3,"file":"tasks-update-one.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-update-one.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwBvB,QAAA,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;CAqBsB,CAAA;AAE1C,OAAO,EAAE,cAAc,EAAE,CAAA"}
1
+ {"version":3,"file":"tasks-update-one.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-update-one.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AA+BvB,QAAA,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;CA+CsB,CAAA;AAE1C,OAAO,EAAE,cAAc,EAAE,CAAA"}
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { createMoveTaskArgs } from '../tool-helpers.js';
3
+ import { DurationParseError, parseDuration } from '../utils/duration-parser.js';
3
4
  const ArgsSchema = {
4
5
  id: z.string().min(1).describe('The ID of the task to update.'),
5
6
  content: z.string().optional().describe('The new content of the task.'),
@@ -18,13 +19,35 @@ const ArgsSchema = {
18
19
  .string()
19
20
  .optional()
20
21
  .describe("The new due date for the task, in natural language (e.g., 'tomorrow at 5pm')."),
22
+ duration: z
23
+ .string()
24
+ .optional()
25
+ .describe('The duration of the task. Use format: "2h" (hours), "90m" (minutes), "2h30m" (combined), or "1.5h" (decimal hours). Max 24h.'),
21
26
  };
22
27
  const tasksUpdateOne = {
23
28
  name: 'tasks-update-one',
24
29
  description: 'Update an existing task with new values.',
25
30
  parameters: ArgsSchema,
26
31
  async execute(args, client) {
27
- const { id, projectId, sectionId, parentId, ...updateArgs } = args;
32
+ const { id, projectId, sectionId, parentId, duration: durationStr, ...otherUpdateArgs } = args;
33
+ let updateArgs = { ...otherUpdateArgs };
34
+ // Parse duration if provided
35
+ if (durationStr) {
36
+ try {
37
+ const { minutes } = parseDuration(durationStr);
38
+ updateArgs = {
39
+ ...updateArgs,
40
+ duration: minutes,
41
+ durationUnit: 'minute',
42
+ };
43
+ }
44
+ catch (error) {
45
+ if (error instanceof DurationParseError) {
46
+ throw new Error(`Task ${id}: ${error.message}`);
47
+ }
48
+ throw error;
49
+ }
50
+ }
28
51
  // If no move parameters are provided, use updateTask without moveTasks
29
52
  if (!projectId && !sectionId && !parentId) {
30
53
  return await client.updateTask(id, updateArgs);
@@ -14,6 +14,7 @@ export type MappedTask = {
14
14
  sectionId: string | null;
15
15
  parentId: string | null;
16
16
  labels: string[];
17
+ duration: string | null;
17
18
  };
18
19
  /**
19
20
  * Creates a mock Task with all required properties and sensible defaults.
@@ -1 +1 @@
1
- {"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["../../src/tools/test-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAEnF;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAA;IAC3B,SAAS,EAAE,MAAM,GAAG,OAAO,CAAA;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,EAAE,MAAM,EAAE,CAAA;CACnB,CAAA;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,GAAE,OAAO,CAAC,IAAI,CAAM,GAAG,IAAI,CA8BlE;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,OAAO,CAAC,OAAO,CAAM,GAAG,OAAO,CAe3E;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,OAAO,CAAC,eAAe,CAAM,GAAG,eAAe,CAuB3F;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EACnC,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,GAAE,MAAM,GAAG,IAAW,GACjC;IACC,OAAO,EAAE,CAAC,EAAE,CAAA;IACZ,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,CAKA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAAG,UAAU,CAchF;AAED;;GAEG;AACH,eAAO,MAAM,WAAW;;;;;CAKd,CAAA;AAEV;;GAEG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,EAC1C,KAAK,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,CAAA;CAAE,CAAC;UAAjC,MAAM;WAAS,CAAC;eAAa,CAAC;IAGtD;AAED;;GAEG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;;CAUX,CAAA"}
1
+ {"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["../../src/tools/test-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAEnF;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAA;IAC3B,SAAS,EAAE,MAAM,GAAG,OAAO,CAAA;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B,CAAA;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,GAAE,OAAO,CAAC,IAAI,CAAM,GAAG,IAAI,CA8BlE;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,OAAO,CAAC,OAAO,CAAM,GAAG,OAAO,CAe3E;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,OAAO,CAAC,eAAe,CAAM,GAAG,eAAe,CAuB3F;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EACnC,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,GAAE,MAAM,GAAG,IAAW,GACjC;IACC,OAAO,EAAE,CAAC,EAAE,CAAA;IACZ,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,CAKA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAAG,UAAU,CAehF;AAED;;GAEG;AACH,eAAO,MAAM,WAAW;;;;;CAKd,CAAA;AAEV;;GAEG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,EAC1C,KAAK,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,CAAA;CAAE,CAAC;UAAjC,MAAM;WAAS,CAAC;eAAa,CAAC;IAGtD;AAED;;GAEG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;;CAUX,CAAA"}
@@ -105,6 +105,7 @@ export function createMappedTask(overrides = {}) {
105
105
  sectionId: null,
106
106
  parentId: null,
107
107
  labels: [],
108
+ duration: null,
108
109
  ...overrides,
109
110
  };
110
111
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Duration parser utility for converting human-readable duration strings
3
+ * to minutes using a restricted, language-neutral syntax.
4
+ *
5
+ * Supported formats:
6
+ * - "2h" (hours only)
7
+ * - "90m" (minutes only)
8
+ * - "2h30m" (hours + minutes)
9
+ * - "1.5h" (decimal hours)
10
+ * - Supports optional spaces: "2h 30m"
11
+ */
12
+ interface ParsedDuration {
13
+ minutes: number;
14
+ }
15
+ export declare class DurationParseError extends Error {
16
+ constructor(input: string, reason: string);
17
+ }
18
+ /**
19
+ * Parses duration string in restricted syntax to minutes.
20
+ * Max duration: 1440 minutes (24 hours)
21
+ *
22
+ * @param durationStr - Duration string like "2h30m", "45m", "1.5h"
23
+ * @returns Parsed duration in minutes
24
+ * @throws DurationParseError for invalid formats
25
+ */
26
+ export declare function parseDuration(durationStr: string): ParsedDuration;
27
+ /**
28
+ * Formats minutes back to a human-readable duration string.
29
+ * Used when returning task data to LLMs.
30
+ *
31
+ * @param minutes - Duration in minutes
32
+ * @returns Formatted duration string like "2h30m" or "45m"
33
+ */
34
+ export declare function formatDuration(minutes: number): string;
35
+ export {};
36
+ //# sourceMappingURL=duration-parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duration-parser.d.ts","sourceRoot":"","sources":["../../src/utils/duration-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,UAAU,cAAc;IACpB,OAAO,EAAE,MAAM,CAAA;CAClB;AAED,qBAAa,kBAAmB,SAAQ,KAAK;gBAC7B,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAI5C;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,CAgEjE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAetD"}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Duration parser utility for converting human-readable duration strings
3
+ * to minutes using a restricted, language-neutral syntax.
4
+ *
5
+ * Supported formats:
6
+ * - "2h" (hours only)
7
+ * - "90m" (minutes only)
8
+ * - "2h30m" (hours + minutes)
9
+ * - "1.5h" (decimal hours)
10
+ * - Supports optional spaces: "2h 30m"
11
+ */
12
+ export class DurationParseError extends Error {
13
+ constructor(input, reason) {
14
+ super(`Invalid duration format "${input}": ${reason}`);
15
+ this.name = 'DurationParseError';
16
+ }
17
+ }
18
+ /**
19
+ * Parses duration string in restricted syntax to minutes.
20
+ * Max duration: 1440 minutes (24 hours)
21
+ *
22
+ * @param durationStr - Duration string like "2h30m", "45m", "1.5h"
23
+ * @returns Parsed duration in minutes
24
+ * @throws DurationParseError for invalid formats
25
+ */
26
+ export function parseDuration(durationStr) {
27
+ if (!durationStr || typeof durationStr !== 'string') {
28
+ throw new DurationParseError(durationStr, 'Duration must be a non-empty string');
29
+ }
30
+ // Remove all spaces and convert to lowercase
31
+ const normalized = durationStr.trim().toLowerCase().replace(/\s+/g, '');
32
+ // Check for empty string after trimming
33
+ if (!normalized) {
34
+ throw new DurationParseError(durationStr, 'Duration must be a non-empty string');
35
+ }
36
+ // Validate format with strict ordering: hours must come before minutes
37
+ // This regex ensures: optional hours followed by optional minutes, no duplicates
38
+ const match = normalized.match(/^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?$/);
39
+ if (!match || (!match[1] && !match[2])) {
40
+ throw new DurationParseError(durationStr, 'Use format like "2h", "30m", "2h30m", or "1.5h"');
41
+ }
42
+ let totalMinutes = 0;
43
+ const [, hoursStr, minutesStr] = match;
44
+ // Parse hours if present
45
+ if (hoursStr) {
46
+ const hours = Number.parseFloat(hoursStr);
47
+ if (Number.isNaN(hours) || hours < 0) {
48
+ throw new DurationParseError(durationStr, 'Hours must be a positive number');
49
+ }
50
+ totalMinutes += hours * 60;
51
+ }
52
+ // Parse minutes if present
53
+ if (minutesStr) {
54
+ const minutes = Number.parseFloat(minutesStr);
55
+ if (Number.isNaN(minutes) || minutes < 0) {
56
+ throw new DurationParseError(durationStr, 'Minutes must be a positive number');
57
+ }
58
+ // Don't allow decimal minutes
59
+ if (minutes % 1 !== 0) {
60
+ throw new DurationParseError(durationStr, 'Minutes must be a whole number (use decimal hours instead)');
61
+ }
62
+ totalMinutes += minutes;
63
+ }
64
+ // The regex already ensures at least one unit is present
65
+ // Round to nearest minute (handles decimal hours)
66
+ totalMinutes = Math.round(totalMinutes);
67
+ // Validate minimum duration
68
+ if (totalMinutes === 0) {
69
+ throw new DurationParseError(durationStr, 'Duration must be greater than 0 minutes');
70
+ }
71
+ // Validate maximum duration (24 hours = 1440 minutes)
72
+ if (totalMinutes > 1440) {
73
+ throw new DurationParseError(durationStr, 'Duration cannot exceed 24 hours (1440 minutes)');
74
+ }
75
+ return { minutes: totalMinutes };
76
+ }
77
+ /**
78
+ * Formats minutes back to a human-readable duration string.
79
+ * Used when returning task data to LLMs.
80
+ *
81
+ * @param minutes - Duration in minutes
82
+ * @returns Formatted duration string like "2h30m" or "45m"
83
+ */
84
+ export function formatDuration(minutes) {
85
+ if (minutes <= 0)
86
+ return '0m';
87
+ const hours = Math.floor(minutes / 60);
88
+ const remainingMinutes = minutes % 60;
89
+ if (hours === 0) {
90
+ return `${remainingMinutes}m`;
91
+ }
92
+ if (remainingMinutes === 0) {
93
+ return `${hours}h`;
94
+ }
95
+ return `${hours}h${remainingMinutes}m`;
96
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=duration-parser.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duration-parser.test.d.ts","sourceRoot":"","sources":["../../src/utils/duration-parser.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,147 @@
1
+ import { DurationParseError, formatDuration, parseDuration } from './duration-parser.js';
2
+ describe('parseDuration', () => {
3
+ describe('valid formats', () => {
4
+ it('should parse hours only', () => {
5
+ expect(parseDuration('2h')).toEqual({ minutes: 120 });
6
+ expect(parseDuration('1h')).toEqual({ minutes: 60 });
7
+ expect(parseDuration('24h')).toEqual({ minutes: 1440 });
8
+ });
9
+ it('should parse minutes only', () => {
10
+ expect(parseDuration('90m')).toEqual({ minutes: 90 });
11
+ expect(parseDuration('45m')).toEqual({ minutes: 45 });
12
+ expect(parseDuration('1m')).toEqual({ minutes: 1 });
13
+ expect(parseDuration('1440m')).toEqual({ minutes: 1440 });
14
+ });
15
+ it('should parse hours and minutes combined', () => {
16
+ expect(parseDuration('2h30m')).toEqual({ minutes: 150 });
17
+ expect(parseDuration('1h45m')).toEqual({ minutes: 105 });
18
+ expect(parseDuration('0h30m')).toEqual({ minutes: 30 });
19
+ expect(parseDuration('23h59m')).toEqual({ minutes: 1439 });
20
+ });
21
+ it('should parse decimal hours', () => {
22
+ expect(parseDuration('1.5h')).toEqual({ minutes: 90 });
23
+ expect(parseDuration('2.25h')).toEqual({ minutes: 135 });
24
+ expect(parseDuration('0.5h')).toEqual({ minutes: 30 });
25
+ expect(parseDuration('0.75h')).toEqual({ minutes: 45 });
26
+ });
27
+ it('should handle spaces in input', () => {
28
+ expect(parseDuration('2h 30m')).toEqual({ minutes: 150 });
29
+ expect(parseDuration(' 1h45m ')).toEqual({ minutes: 105 });
30
+ expect(parseDuration(' 2h ')).toEqual({ minutes: 120 });
31
+ expect(parseDuration(' 90m ')).toEqual({ minutes: 90 });
32
+ });
33
+ it('should handle case insensitive input', () => {
34
+ expect(parseDuration('2H')).toEqual({ minutes: 120 });
35
+ expect(parseDuration('90M')).toEqual({ minutes: 90 });
36
+ expect(parseDuration('2H30M')).toEqual({ minutes: 150 });
37
+ expect(parseDuration('1.5H')).toEqual({ minutes: 90 });
38
+ });
39
+ it('should round decimal minutes from decimal hours', () => {
40
+ expect(parseDuration('1.33h')).toEqual({ minutes: 80 }); // 1.33 * 60 = 79.8 -> 80
41
+ expect(parseDuration('1.67h')).toEqual({ minutes: 100 }); // 1.67 * 60 = 100.2 -> 100
42
+ });
43
+ });
44
+ describe('invalid formats', () => {
45
+ it('should throw error for empty or null input', () => {
46
+ expect(() => parseDuration('')).toThrow(DurationParseError);
47
+ expect(() => parseDuration(' ')).toThrow('Duration must be a non-empty string');
48
+ // biome-ignore lint/suspicious/noExplicitAny: Testing error cases with invalid types
49
+ expect(() => parseDuration(null)).toThrow('Duration must be a non-empty string');
50
+ // biome-ignore lint/suspicious/noExplicitAny: Testing error cases with invalid types
51
+ expect(() => parseDuration(undefined)).toThrow('Duration must be a non-empty string');
52
+ });
53
+ it('should throw error for invalid format', () => {
54
+ expect(() => parseDuration('2')).toThrow('Use format like "2h", "30m", "2h30m", or "1.5h"');
55
+ expect(() => parseDuration('2hours')).toThrow('Use format like');
56
+ expect(() => parseDuration('2h30')).toThrow('Use format like');
57
+ expect(() => parseDuration('h30m')).toThrow('Use format like');
58
+ expect(() => parseDuration('2x30m')).toThrow('Use format like');
59
+ expect(() => parseDuration('2h30s')).toThrow('Use format like');
60
+ });
61
+ it('should throw error for decimal minutes', () => {
62
+ expect(() => parseDuration('90.5m')).toThrow('Minutes must be a whole number');
63
+ expect(() => parseDuration('1h30.5m')).toThrow('Minutes must be a whole number');
64
+ });
65
+ it('should throw error for negative values', () => {
66
+ expect(() => parseDuration('-2h')).toThrow('Use format like');
67
+ expect(() => parseDuration('-30m')).toThrow('Use format like');
68
+ expect(() => parseDuration('2h-30m')).toThrow('Use format like');
69
+ });
70
+ it('should throw error for zero duration', () => {
71
+ expect(() => parseDuration('0h')).toThrow('Duration must be greater than 0 minutes');
72
+ expect(() => parseDuration('0m')).toThrow('Duration must be greater than 0 minutes');
73
+ expect(() => parseDuration('0h0m')).toThrow('Duration must be greater than 0 minutes');
74
+ });
75
+ it('should throw error for duration exceeding 24 hours', () => {
76
+ expect(() => parseDuration('25h')).toThrow('Duration cannot exceed 24 hours (1440 minutes)');
77
+ expect(() => parseDuration('1441m')).toThrow('Duration cannot exceed 24 hours (1440 minutes)');
78
+ expect(() => parseDuration('24h1m')).toThrow('Duration cannot exceed 24 hours (1440 minutes)');
79
+ expect(() => parseDuration('24.1h')).toThrow('Duration cannot exceed 24 hours (1440 minutes)');
80
+ });
81
+ it('should throw error for malformed numbers', () => {
82
+ expect(() => parseDuration('2.h')).toThrow('Use format like');
83
+ expect(() => parseDuration('2h.m')).toThrow('Use format like');
84
+ expect(() => parseDuration('2..5h')).toThrow('Use format like');
85
+ });
86
+ it('should throw error for duplicate units', () => {
87
+ expect(() => parseDuration('2h3h')).toThrow('Use format like');
88
+ expect(() => parseDuration('30m45m')).toThrow('Use format like');
89
+ });
90
+ it('should throw error for wrong order (minutes before hours)', () => {
91
+ expect(() => parseDuration('30m2h')).toThrow('Use format like');
92
+ expect(() => parseDuration('45m1h')).toThrow('Use format like');
93
+ });
94
+ it('should throw error for invalid mixed formats with correct order', () => {
95
+ expect(() => parseDuration('2h30m15h')).toThrow('Use format like');
96
+ expect(() => parseDuration('1h2h30m')).toThrow('Use format like');
97
+ });
98
+ });
99
+ describe('edge cases', () => {
100
+ it('should handle maximum allowed duration', () => {
101
+ expect(parseDuration('24h')).toEqual({ minutes: 1440 });
102
+ expect(parseDuration('1440m')).toEqual({ minutes: 1440 });
103
+ expect(parseDuration('23h60m')).toEqual({ minutes: 1440 });
104
+ });
105
+ it('should handle minimum allowed duration', () => {
106
+ expect(parseDuration('1m')).toEqual({ minutes: 1 });
107
+ expect(parseDuration('0.017h')).toEqual({ minutes: 1 }); // 0.017 * 60 = 1.02 -> 1
108
+ });
109
+ });
110
+ });
111
+ describe('formatDuration', () => {
112
+ it('should format minutes only', () => {
113
+ expect(formatDuration(45)).toBe('45m');
114
+ expect(formatDuration(1)).toBe('1m');
115
+ expect(formatDuration(59)).toBe('59m');
116
+ });
117
+ it('should format hours only', () => {
118
+ expect(formatDuration(60)).toBe('1h');
119
+ expect(formatDuration(120)).toBe('2h');
120
+ expect(formatDuration(1440)).toBe('24h');
121
+ });
122
+ it('should format hours and minutes combined', () => {
123
+ expect(formatDuration(90)).toBe('1h30m');
124
+ expect(formatDuration(150)).toBe('2h30m');
125
+ expect(formatDuration(105)).toBe('1h45m');
126
+ expect(formatDuration(1439)).toBe('23h59m');
127
+ });
128
+ it('should handle edge cases', () => {
129
+ expect(formatDuration(0)).toBe('0m');
130
+ expect(formatDuration(-5)).toBe('0m');
131
+ });
132
+ });
133
+ describe('round trip parsing and formatting', () => {
134
+ const testCases = [
135
+ { input: '2h', expectedMinutes: 120, expectedFormat: '2h' },
136
+ { input: '45m', expectedMinutes: 45, expectedFormat: '45m' },
137
+ { input: '2h30m', expectedMinutes: 150, expectedFormat: '2h30m' },
138
+ { input: '1.5h', expectedMinutes: 90, expectedFormat: '1h30m' },
139
+ ];
140
+ for (const { input, expectedMinutes, expectedFormat } of testCases) {
141
+ it(`should parse "${input}" and format back consistently`, () => {
142
+ const parsed = parseDuration(input);
143
+ expect(parsed.minutes).toBe(expectedMinutes);
144
+ expect(formatDuration(parsed.minutes)).toBe(expectedFormat);
145
+ });
146
+ }
147
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/todoist-ai",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",