@walknpm/todo-cli 1.2.0 → 2.0.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.
package/src/todo.js CHANGED
@@ -4,6 +4,8 @@
4
4
  export const PRIORITIES = ["high", "medium", "low"];
5
5
  const PRIORITY_RANK = { high: 0, medium: 1, low: 2 };
6
6
 
7
+ const RECURRENCE_FREQS = ["daily", "weekly", "monthly", "yearly"];
8
+
7
9
  /** Normalise and validate a priority string. Defaults to "medium". */
8
10
  export function normalizePriority(p) {
9
11
  const v = String(p ?? "").toLowerCase().trim();
@@ -52,16 +54,73 @@ export function dueStatus(due) {
52
54
  return null;
53
55
  }
54
56
 
57
+ /** Add days to a YYYY-MM-DD date string and return a new YYYY-MM-DD. */
58
+ export function addDaysToDate(dateStr, days) {
59
+ const d = new Date(dateStr + "T00:00:00");
60
+ d.setDate(d.getDate() + days);
61
+ const y = d.getFullYear();
62
+ const m = String(d.getMonth() + 1).padStart(2, "0");
63
+ const day = String(d.getDate()).padStart(2, "0");
64
+ return `${y}-${m}-${day}`;
65
+ }
66
+
67
+ /** Add months to a YYYY-MM-DD date string. */
68
+ export function addMonthsToDate(dateStr, months) {
69
+ const d = new Date(dateStr + "T00:00:00");
70
+ d.setMonth(d.getMonth() + months);
71
+ const y = d.getFullYear();
72
+ const m = String(d.getMonth() + 1).padStart(2, "0");
73
+ const day = String(d.getDate()).padStart(2, "0");
74
+ return `${y}-${m}-${day}`;
75
+ }
76
+
77
+ /** Add years to a YYYY-MM-DD date string. */
78
+ export function addYearsToDate(dateStr, years) {
79
+ const d = new Date(dateStr + "T00:00:00");
80
+ d.setFullYear(d.getFullYear() + years);
81
+ const y = d.getFullYear();
82
+ const m = String(d.getMonth() + 1).padStart(2, "0");
83
+ const day = String(d.getDate()).padStart(2, "0");
84
+ return `${y}-${m}-${day}`;
85
+ }
86
+
55
87
  function nextId(todos) {
56
88
  return todos.reduce((max, t) => Math.max(max, t.id), 0) + 1;
57
89
  }
58
90
 
91
+ /** Ensure a todo has all new fields (backward-compat migration). */
92
+ function migrate(todo) {
93
+ if (!todo.subtasks) todo.subtasks = [];
94
+ if (todo.recurring === undefined) todo.recurring = null;
95
+ if (!todo.dependsOn) todo.dependsOn = [];
96
+ if (todo.timeLogged === undefined) todo.timeLogged = 0;
97
+ if (todo.timerStartedAt === undefined) todo.timerStartedAt = null;
98
+ if (todo.pinned === undefined) todo.pinned = false;
99
+ if (todo.estimate === undefined) todo.estimate = null;
100
+ if (todo.assignee === undefined) todo.assignee = "";
101
+ if (todo.color === undefined) todo.color = "";
102
+ if (todo.order === undefined) todo.order = todo.id;
103
+ if (todo.completedAt === undefined) todo.completedAt = null;
104
+ return todo;
105
+ }
106
+
107
+ /** Migrate all todos in place. */
108
+ export function migrateAll(todos) {
109
+ for (const t of todos) migrate(t);
110
+ return todos;
111
+ }
112
+
113
+ // ─── Core CRUD ─────────────────────────────────────────────────────────────
114
+
59
115
  /** Add a new todo. Returns the created todo object. */
60
- export function addTodo(todos, { title, priority, tags, category, due, note } = {}) {
116
+ export function addTodo(todos, {
117
+ title, priority, tags, category, due, note,
118
+ estimate, assignee, color, pinned,
119
+ } = {}) {
61
120
  if (!title || !title.trim()) {
62
121
  throw new Error("Title is required.");
63
122
  }
64
- const todo = {
123
+ const todo = migrate({
65
124
  id: nextId(todos),
66
125
  title: title.trim(),
67
126
  done: false,
@@ -71,7 +130,18 @@ export function addTodo(todos, { title, priority, tags, category, due, note } =
71
130
  due: parseDueDate(due),
72
131
  note: note ? String(note).trim() : "",
73
132
  createdAt: new Date().toISOString(),
74
- };
133
+ subtasks: [],
134
+ recurring: null,
135
+ dependsOn: [],
136
+ timeLogged: 0,
137
+ timerStartedAt: null,
138
+ pinned: Boolean(pinned),
139
+ estimate: estimate != null ? Math.max(0, Number(estimate) || 0) : null,
140
+ assignee: assignee ? String(assignee).trim() : "",
141
+ color: color ? String(color).trim() : "",
142
+ order: nextId(todos),
143
+ completedAt: null,
144
+ });
75
145
  todos.push(todo);
76
146
  return todo;
77
147
  }
@@ -81,25 +151,41 @@ function indexOf(todos, id) {
81
151
  return todos.findIndex((t) => t.id === Number(id));
82
152
  }
83
153
 
154
+ /** Get a todo by id. Returns the todo or null. */
155
+ export function getTodo(todos, id) {
156
+ const i = indexOf(todos, id);
157
+ return i === -1 ? null : migrate(todos[i]);
158
+ }
159
+
84
160
  /** Delete a todo by id. Returns true if something was removed. */
85
161
  export function deleteTodo(todos, id) {
86
162
  const i = indexOf(todos, id);
87
163
  if (i === -1) return false;
88
164
  todos.splice(i, 1);
165
+ // Remove this id from any dependency lists.
166
+ for (const t of todos) {
167
+ t.dependsOn = t.dependsOn.filter((d) => d !== Number(id));
168
+ }
89
169
  return true;
90
170
  }
91
171
 
92
172
  /** Edit an existing todo by id with partial fields. Returns the updated todo or null. */
93
- export function editTodo(todos, id, { title, priority, tags, category, due, note } = {}) {
173
+ export function editTodo(todos, id, {
174
+ title, priority, tags, category, due, note,
175
+ estimate, assignee, color,
176
+ } = {}) {
94
177
  const i = indexOf(todos, id);
95
178
  if (i === -1) return null;
96
- const todo = todos[i];
179
+ const todo = migrate(todos[i]);
97
180
  if (title != null) todo.title = title.trim();
98
181
  if (priority != null) todo.priority = normalizePriority(priority);
99
182
  if (tags != null) todo.tags = parseTags(tags);
100
183
  if (category != null) todo.category = String(category).trim();
101
184
  if (due !== undefined) todo.due = parseDueDate(due);
102
185
  if (note !== undefined) todo.note = String(note).trim();
186
+ if (estimate !== undefined) todo.estimate = estimate != null ? Math.max(0, Number(estimate) || 0) : null;
187
+ if (assignee !== undefined) todo.assignee = String(assignee).trim();
188
+ if (color !== undefined) todo.color = String(color).trim();
103
189
  todo.updatedAt = new Date().toISOString();
104
190
  return todo;
105
191
  }
@@ -108,20 +194,400 @@ export function editTodo(todos, id, { title, priority, tags, category, due, note
108
194
  export function toggleTodo(todos, id) {
109
195
  const i = indexOf(todos, id);
110
196
  if (i === -1) return null;
111
- todos[i].done = !todos[i].done;
112
- todos[i].updatedAt = new Date().toISOString();
113
- return todos[i];
197
+ const todo = migrate(todos[i]);
198
+ todo.done = !todo.done;
199
+ todo.updatedAt = new Date().toISOString();
200
+ if (todo.done) {
201
+ todo.completedAt = new Date().toISOString();
202
+ // Handle recurring: create a new instance and reset this one.
203
+ if (todo.recurring) {
204
+ _rescheduleRecurring(todos, todo);
205
+ }
206
+ } else {
207
+ todo.completedAt = null;
208
+ }
209
+ return todo;
114
210
  }
115
211
 
116
212
  /** Mark a todo done by id. Returns the updated todo or null. */
117
213
  export function completeTodo(todos, id) {
118
214
  const i = indexOf(todos, id);
119
215
  if (i === -1) return null;
120
- todos[i].done = true;
121
- todos[i].updatedAt = new Date().toISOString();
122
- return todos[i];
216
+ const todo = migrate(todos[i]);
217
+ if (!todo.done) {
218
+ todo.done = true;
219
+ todo.completedAt = new Date().toISOString();
220
+ todo.updatedAt = new Date().toISOString();
221
+ if (todo.recurring) {
222
+ _rescheduleRecurring(todos, todo);
223
+ }
224
+ }
225
+ return todo;
226
+ }
227
+
228
+ /** Mark ALL pending todos as done. Returns the count completed. */
229
+ export function completeAll(todos) {
230
+ let n = 0;
231
+ const now = new Date().toISOString();
232
+ for (const t of todos) {
233
+ migrate(t);
234
+ if (!t.done) {
235
+ t.done = true;
236
+ t.completedAt = now;
237
+ t.updatedAt = now;
238
+ n++;
239
+ if (t.recurring) _rescheduleRecurring(todos, t);
240
+ }
241
+ }
242
+ return n;
243
+ }
244
+
245
+ // ─── Recurring ─────────────────────────────────────────────────────────────
246
+
247
+ /** Set recurrence on a todo. freq: daily|weekly|monthly|yearly, interval: n. */
248
+ export function setRecurring(todos, id, freq, interval = 1) {
249
+ const i = indexOf(todos, id);
250
+ if (i === -1) return null;
251
+ const todo = migrate(todos[i]);
252
+ const f = String(freq).toLowerCase().trim();
253
+ if (!RECURRENCE_FREQS.includes(f)) {
254
+ throw new Error(`Invalid recurrence frequency "${freq}". Use ${RECURRENCE_FREQS.join(", ")}.`);
255
+ }
256
+ const n = Math.max(1, Number(interval) || 1);
257
+ todo.recurring = { freq: f, interval: n };
258
+ todo.updatedAt = new Date().toISOString();
259
+ return todo;
260
+ }
261
+
262
+ /** Remove recurrence from a todo. */
263
+ export function removeRecurring(todos, id) {
264
+ const i = indexOf(todos, id);
265
+ if (i === -1) return null;
266
+ const todo = migrate(todos[i]);
267
+ todo.recurring = null;
268
+ todo.updatedAt = new Date().toISOString();
269
+ return todo;
270
+ }
271
+
272
+ /** Internal: when a recurring todo is completed, create a new pending clone and reopen the original. */
273
+ function _rescheduleRecurring(todos, todo) {
274
+ const newTodo = migrate({
275
+ ...structuredClone(todo),
276
+ id: nextId(todos),
277
+ done: false,
278
+ completedAt: null,
279
+ timerStartedAt: null,
280
+ createdAt: new Date().toISOString(),
281
+ updatedAt: new Date().toISOString(),
282
+ order: nextId(todos),
283
+ });
284
+ // Advance the due date.
285
+ if (todo.due) {
286
+ const { freq, interval } = todo.recurring;
287
+ if (freq === "daily") newTodo.due = addDaysToDate(todo.due, interval);
288
+ else if (freq === "weekly") newTodo.due = addDaysToDate(todo.due, interval * 7);
289
+ else if (freq === "monthly") newTodo.due = addMonthsToDate(todo.due, interval);
290
+ else if (freq === "yearly") newTodo.due = addYearsToDate(todo.due, interval);
291
+ }
292
+ // Reset subtasks.
293
+ newTodo.subtasks = newTodo.subtasks.map((s) => ({ ...s, done: false }));
294
+ todos.push(newTodo);
295
+ }
296
+
297
+ // ─── Subtasks ──────────────────────────────────────────────────────────────
298
+
299
+ /** Add a subtask to a todo. Returns the subtask. */
300
+ export function addSubtask(todos, id, title) {
301
+ const i = indexOf(todos, id);
302
+ if (i === -1) return null;
303
+ if (!title || !title.trim()) throw new Error("Subtask title is required.");
304
+ const todo = migrate(todos[i]);
305
+ const sid = todo.subtasks.reduce((max, s) => Math.max(max, s.id), 0) + 1;
306
+ const subtask = { id: sid, title: title.trim(), done: false };
307
+ todo.subtasks.push(subtask);
308
+ todo.updatedAt = new Date().toISOString();
309
+ return subtask;
310
+ }
311
+
312
+ /** Toggle a subtask's done status. Returns the subtask or null. */
313
+ export function toggleSubtask(todos, id, subtaskId) {
314
+ const i = indexOf(todos, id);
315
+ if (i === -1) return null;
316
+ const todo = migrate(todos[i]);
317
+ const st = todo.subtasks.find((s) => s.id === Number(subtaskId));
318
+ if (!st) return null;
319
+ st.done = !st.done;
320
+ todo.updatedAt = new Date().toISOString();
321
+ return st;
322
+ }
323
+
324
+ /** Delete a subtask. Returns true if removed. */
325
+ export function deleteSubtask(todos, id, subtaskId) {
326
+ const i = indexOf(todos, id);
327
+ if (i === -1) return false;
328
+ const todo = migrate(todos[i]);
329
+ const idx = todo.subtasks.findIndex((s) => s.id === Number(subtaskId));
330
+ if (idx === -1) return false;
331
+ todo.subtasks.splice(idx, 1);
332
+ todo.updatedAt = new Date().toISOString();
333
+ return true;
334
+ }
335
+
336
+ /** Edit a subtask title. Returns the subtask or null. */
337
+ export function editSubtask(todos, id, subtaskId, title) {
338
+ const i = indexOf(todos, id);
339
+ if (i === -1) return null;
340
+ const todo = migrate(todos[i]);
341
+ const st = todo.subtasks.find((s) => s.id === Number(subtaskId));
342
+ if (!st) return null;
343
+ st.title = title.trim();
344
+ todo.updatedAt = new Date().toISOString();
345
+ return st;
346
+ }
347
+
348
+ // ─── Dependencies ──────────────────────────────────────────────────────────
349
+
350
+ /** Add a dependency: todo `id` depends on todo `depId`. */
351
+ export function addDependency(todos, id, depId) {
352
+ const i = indexOf(todos, id);
353
+ if (i === -1) return null;
354
+ const todo = migrate(todos[i]);
355
+ const d = Number(depId);
356
+ if (d === todo.id) throw new Error("A todo cannot depend on itself.");
357
+ if (!todos.some((t) => t.id === d)) throw new Error(`Todo #${d} does not exist.`);
358
+ if (!todo.dependsOn.includes(d)) todo.dependsOn.push(d);
359
+ todo.updatedAt = new Date().toISOString();
360
+ return todo;
361
+ }
362
+
363
+ /** Remove a dependency. */
364
+ export function removeDependency(todos, id, depId) {
365
+ const i = indexOf(todos, id);
366
+ if (i === -1) return null;
367
+ const todo = migrate(todos[i]);
368
+ todo.dependsOn = todo.dependsOn.filter((d) => d !== Number(depId));
369
+ todo.updatedAt = new Date().toISOString();
370
+ return todo;
371
+ }
372
+
373
+ /** Check if a todo is blocked by incomplete dependencies. Returns array of blocking todo IDs. */
374
+ export function getBlockedBy(todos, id) {
375
+ const todo = getTodo(todos, id);
376
+ if (!todo) return [];
377
+ return todo.dependsOn.filter((depId) => {
378
+ const dep = getTodo(todos, depId);
379
+ return dep && !dep.done;
380
+ });
381
+ }
382
+
383
+ // ─── Time Tracking ─────────────────────────────────────────────────────────
384
+
385
+ /** Start a timer on a todo. Throws if one is already running. */
386
+ export function startTimer(todos, id) {
387
+ const i = indexOf(todos, id);
388
+ if (i === -1) return null;
389
+ const todo = migrate(todos[i]);
390
+ // Stop any other running timer.
391
+ for (const t of todos) {
392
+ if (t.timerStartedAt && t.id !== todo.id) {
393
+ _stopTimer(t);
394
+ }
395
+ }
396
+ todo.timerStartedAt = new Date().toISOString();
397
+ todo.updatedAt = todo.timerStartedAt;
398
+ return todo;
399
+ }
400
+
401
+ /** Stop the timer on a todo and log elapsed time. Returns the todo or null. */
402
+ export function stopTimer(todos, id) {
403
+ const i = indexOf(todos, id);
404
+ if (i === -1) return null;
405
+ const todo = migrate(todos[i]);
406
+ if (!todo.timerStartedAt) return todo;
407
+ _stopTimer(todo);
408
+ todo.updatedAt = new Date().toISOString();
409
+ return todo;
410
+ }
411
+
412
+ function _stopTimer(todo) {
413
+ if (!todo.timerStartedAt) return;
414
+ const elapsed = Math.round((Date.now() - new Date(todo.timerStartedAt).getTime()) / 60000);
415
+ if (elapsed > 0) todo.timeLogged = (todo.timeLogged || 0) + elapsed;
416
+ todo.timerStartedAt = null;
123
417
  }
124
418
 
419
+ /** Manually log time on a todo. */
420
+ export function logTime(todos, id, minutes) {
421
+ const i = indexOf(todos, id);
422
+ if (i === -1) return null;
423
+ const todo = migrate(todos[i]);
424
+ const m = Number(minutes);
425
+ if (isNaN(m) || m <= 0) throw new Error("Minutes must be a positive number.");
426
+ todo.timeLogged = (todo.timeLogged || 0) + m;
427
+ todo.updatedAt = new Date().toISOString();
428
+ return todo;
429
+ }
430
+
431
+ /** Get the currently running timer todo, if any. */
432
+ export function getActiveTimer(todos) {
433
+ return todos.find((t) => t.timerStartedAt) || null;
434
+ }
435
+
436
+ // ─── Pinning ───────────────────────────────────────────────────────────────
437
+
438
+ /** Toggle pin status. Returns the todo or null. */
439
+ export function togglePin(todos, id) {
440
+ const i = indexOf(todos, id);
441
+ if (i === -1) return null;
442
+ const todo = migrate(todos[i]);
443
+ todo.pinned = !todo.pinned;
444
+ todo.updatedAt = new Date().toISOString();
445
+ return todo;
446
+ }
447
+
448
+ /** Pin a todo. */
449
+ export function pinTodo(todos, id) {
450
+ const i = indexOf(todos, id);
451
+ if (i === -1) return null;
452
+ const todo = migrate(todos[i]);
453
+ todo.pinned = true;
454
+ todo.updatedAt = new Date().toISOString();
455
+ return todo;
456
+ }
457
+
458
+ /** Unpin a todo. */
459
+ export function unpinTodo(todos, id) {
460
+ const i = indexOf(todos, id);
461
+ if (i === -1) return null;
462
+ const todo = migrate(todos[i]);
463
+ todo.pinned = false;
464
+ todo.updatedAt = new Date().toISOString();
465
+ return todo;
466
+ }
467
+
468
+ // ─── Duplicate ─────────────────────────────────────────────────────────────
469
+
470
+ /** Duplicate a todo. Returns the new todo or null. */
471
+ export function duplicateTodo(todos, id) {
472
+ const i = indexOf(todos, id);
473
+ if (i === -1) return null;
474
+ const orig = migrate(todos[i]);
475
+ const now = new Date().toISOString();
476
+ const copy = migrate({
477
+ ...structuredClone(orig),
478
+ id: nextId(todos),
479
+ done: false,
480
+ completedAt: null,
481
+ timerStartedAt: null,
482
+ timeLogged: 0,
483
+ createdAt: now,
484
+ updatedAt: now,
485
+ order: nextId(todos),
486
+ title: orig.title + " (copy)",
487
+ });
488
+ copy.subtasks = copy.subtasks.map((s) => ({ ...s, done: false }));
489
+ todos.push(copy);
490
+ return copy;
491
+ }
492
+
493
+ // ─── Bulk Operations ───────────────────────────────────────────────────────
494
+
495
+ /** Complete multiple todos by IDs. Returns count completed. */
496
+ export function bulkComplete(todos, ids) {
497
+ let n = 0;
498
+ const now = new Date().toISOString();
499
+ for (const id of ids) {
500
+ const todo = getTodo(todos, id);
501
+ if (todo && !todo.done) {
502
+ todo.done = true;
503
+ todo.completedAt = now;
504
+ todo.updatedAt = now;
505
+ n++;
506
+ if (todo.recurring) _rescheduleRecurring(todos, todo);
507
+ }
508
+ }
509
+ return n;
510
+ }
511
+
512
+ /** Delete multiple todos by IDs. Returns count deleted. */
513
+ export function bulkDelete(todos, ids) {
514
+ let n = 0;
515
+ const idSet = new Set(ids.map(Number));
516
+ for (let i = todos.length - 1; i >= 0; i--) {
517
+ if (idSet.has(todos[i].id)) {
518
+ todos.splice(i, 1);
519
+ n++;
520
+ }
521
+ }
522
+ // Clean up dependency references.
523
+ for (const t of todos) {
524
+ t.dependsOn = t.dependsOn.filter((d) => !idSet.has(d));
525
+ }
526
+ return n;
527
+ }
528
+
529
+ /** Set priority on multiple todos. Returns count updated. */
530
+ export function bulkPriority(todos, ids, priority) {
531
+ const p = normalizePriority(priority);
532
+ let n = 0;
533
+ const now = new Date().toISOString();
534
+ for (const id of ids) {
535
+ const todo = getTodo(todos, id);
536
+ if (todo) {
537
+ todo.priority = p;
538
+ todo.updatedAt = now;
539
+ n++;
540
+ }
541
+ }
542
+ return n;
543
+ }
544
+
545
+ /** Set category on multiple todos. Returns count updated. */
546
+ export function bulkCategory(todos, ids, category) {
547
+ let n = 0;
548
+ const now = new Date().toISOString();
549
+ const c = category ? String(category).trim() : "";
550
+ for (const id of ids) {
551
+ const todo = getTodo(todos, id);
552
+ if (todo) {
553
+ todo.category = c;
554
+ todo.updatedAt = now;
555
+ n++;
556
+ }
557
+ }
558
+ return n;
559
+ }
560
+
561
+ /** Set assignee on multiple todos. Returns count updated. */
562
+ export function bulkAssign(todos, ids, assignee) {
563
+ let n = 0;
564
+ const now = new Date().toISOString();
565
+ const a = assignee ? String(assignee).trim() : "";
566
+ for (const id of ids) {
567
+ const todo = getTodo(todos, id);
568
+ if (todo) {
569
+ todo.assignee = a;
570
+ todo.updatedAt = now;
571
+ n++;
572
+ }
573
+ }
574
+ return n;
575
+ }
576
+
577
+ // ─── Reorder ───────────────────────────────────────────────────────────────
578
+
579
+ /** Move a todo to a new position in manual order. */
580
+ export function reorderTodo(todos, id, newOrder) {
581
+ const i = indexOf(todos, id);
582
+ if (i === -1) return null;
583
+ const todo = migrate(todos[i]);
584
+ todo.order = Number(newOrder);
585
+ todo.updatedAt = new Date().toISOString();
586
+ return todo;
587
+ }
588
+
589
+ // ─── Listing & Filtering ───────────────────────────────────────────────────
590
+
125
591
  /**
126
592
  * Filter and sort the todos.
127
593
  * @param {Array} todos
@@ -130,11 +596,13 @@ export function completeTodo(todos, id) {
130
596
  * @param {string} [filter.category] Exact category match.
131
597
  * @param {string} [filter.tag] Exact tag match.
132
598
  * @param {string} [filter.priority] "high" | "medium" | "low"
133
- * @param {string} [filter.sort] "priority" | "created" | "id"
599
+ * @param {string} [filter.assignee] Filter by assignee.
600
+ * @param {string} [filter.sort] "priority" | "created" | "id" | "due" | "title" | "updated"
601
+ * @param {boolean} [filter.pinnedFirst] Pin items to top.
134
602
  */
135
603
  export function listTodos(todos, filter = {}) {
136
604
  const status = filter.status ?? "all";
137
- let result = todos.slice();
605
+ let result = todos.slice().map(migrate);
138
606
 
139
607
  if (status === "done") result = result.filter((t) => t.done);
140
608
  else if (status === "pending") result = result.filter((t) => !t.done);
@@ -151,6 +619,16 @@ export function listTodos(todos, filter = {}) {
151
619
  const p = normalizePriority(filter.priority);
152
620
  result = result.filter((t) => t.priority === p);
153
621
  }
622
+ if (filter.assignee) {
623
+ const a = String(filter.assignee).toLowerCase().trim();
624
+ result = result.filter((t) => t.assignee.toLowerCase() === a);
625
+ }
626
+ if (filter.overdue) {
627
+ result = result.filter((t) => !t.done && dueStatus(t.due) === "overdue");
628
+ }
629
+ if (filter.dueToday) {
630
+ result = result.filter((t) => !t.done && dueStatus(t.due) === "today");
631
+ }
154
632
 
155
633
  const sort = filter.sort ?? "priority";
156
634
  switch (sort) {
@@ -168,6 +646,15 @@ export function listTodos(todos, filter = {}) {
168
646
  return a.due.localeCompare(b.due);
169
647
  });
170
648
  break;
649
+ case "title":
650
+ result.sort((a, b) => a.title.localeCompare(b.title));
651
+ break;
652
+ case "updated":
653
+ result.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
654
+ break;
655
+ case "order":
656
+ result.sort((a, b) => (a.order || 0) - (b.order || 0));
657
+ break;
171
658
  case "priority":
172
659
  default:
173
660
  result.sort((a, b) => {
@@ -176,6 +663,12 @@ export function listTodos(todos, filter = {}) {
176
663
  });
177
664
  break;
178
665
  }
666
+
667
+ // Pin items to top if requested.
668
+ if (filter.pinnedFirst) {
669
+ result.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
670
+ }
671
+
179
672
  return result;
180
673
  }
181
674
 
@@ -186,20 +679,24 @@ export function clearTodos(todos) {
186
679
  return n;
187
680
  }
188
681
 
189
- /** Search todos by keyword across title, tags, category, and note. */
682
+ /** Search todos by keyword across title, tags, category, note, and assignee. */
190
683
  export function searchTodos(todos, keyword) {
191
684
  if (!keyword) return [];
192
685
  const kw = String(keyword).toLowerCase().trim();
193
686
  return todos.filter((t) => {
687
+ migrate(t);
194
688
  return (
195
689
  t.title.toLowerCase().includes(kw) ||
196
690
  t.category.toLowerCase().includes(kw) ||
197
691
  t.note.toLowerCase().includes(kw) ||
692
+ t.assignee.toLowerCase().includes(kw) ||
198
693
  t.tags.some((tag) => tag.includes(kw))
199
694
  );
200
695
  });
201
696
  }
202
697
 
698
+ // ─── Statistics ────────────────────────────────────────────────────────────
699
+
203
700
  /** Compute summary statistics over all todos. */
204
701
  export function getStats(todos) {
205
702
  const stats = {
@@ -207,35 +704,34 @@ export function getStats(todos) {
207
704
  done: 0,
208
705
  pending: 0,
209
706
  overdue: 0,
707
+ dueToday: 0,
708
+ pinned: 0,
709
+ totalTimeLogged: 0,
210
710
  byPriority: { high: 0, medium: 0, low: 0 },
211
711
  byCategory: {},
712
+ byAssignee: {},
713
+ completionRate: 0,
212
714
  };
213
715
  for (const t of todos) {
716
+ migrate(t);
214
717
  if (t.done) stats.done++;
215
718
  else stats.pending++;
216
719
  stats.byPriority[t.priority] = (stats.byPriority[t.priority] ?? 0) + 1;
217
720
  if (t.category) {
218
721
  stats.byCategory[t.category] = (stats.byCategory[t.category] ?? 0) + 1;
219
722
  }
723
+ if (t.assignee) {
724
+ stats.byAssignee[t.assignee] = (stats.byAssignee[t.assignee] ?? 0) + 1;
725
+ }
726
+ if (t.pinned) stats.pinned++;
727
+ stats.totalTimeLogged += t.timeLogged || 0;
220
728
  if (!t.done && dueStatus(t.due) === "overdue") stats.overdue++;
729
+ if (!t.done && dueStatus(t.due) === "today") stats.dueToday++;
221
730
  }
731
+ stats.completionRate = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0;
222
732
  return stats;
223
733
  }
224
734
 
225
- /** Mark ALL pending todos as done. Returns the count completed. */
226
- export function completeAll(todos) {
227
- let n = 0;
228
- const now = new Date().toISOString();
229
- for (const t of todos) {
230
- if (!t.done) {
231
- t.done = true;
232
- t.updatedAt = now;
233
- n++;
234
- }
235
- }
236
- return n;
237
- }
238
-
239
735
  /** Remove all completed todos. Returns the count archived (removed). */
240
736
  export function archiveCompleted(todos) {
241
737
  let n = 0;
@@ -248,6 +744,8 @@ export function archiveCompleted(todos) {
248
744
  return n;
249
745
  }
250
746
 
747
+ // ─── Export ────────────────────────────────────────────────────────────────
748
+
251
749
  /** Export todos to a string in the given format: "json" | "csv" | "md". */
252
750
  export function exportTodos(todos, format = "json") {
253
751
  switch (format) {
@@ -257,20 +755,21 @@ export function exportTodos(todos, format = "json") {
257
755
  return exportMarkdown(todos);
258
756
  case "json":
259
757
  default:
260
- return JSON.stringify(todos, null, 2);
758
+ return JSON.stringify(todos.map(migrate), null, 2);
261
759
  }
262
760
  }
263
761
 
264
762
  function exportCSV(todos) {
265
- const cols = ["id", "title", "done", "priority", "category", "tags", "due", "note"];
763
+ const cols = ["id", "title", "done", "priority", "category", "tags", "due", "note", "assignee", "estimate", "timeLogged", "pinned"];
266
764
  const esc = (v) => {
267
765
  const s = String(v ?? "");
268
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
766
+ return /[",\n]/.test(s) ? `\"${s.replace(/"/g, '""')}\"` : s;
269
767
  };
270
768
  const lines = [cols.join(",")];
271
769
  for (const t of todos) {
770
+ migrate(t);
272
771
  lines.push(
273
- [t.id, t.title, t.done, t.priority, t.category, t.tags.join(";"), t.due, t.note]
772
+ [t.id, t.title, t.done, t.priority, t.category, t.tags.join(";"), t.due, t.note, t.assignee, t.estimate ?? "", t.timeLogged, t.pinned]
274
773
  .map(esc)
275
774
  .join(",")
276
775
  );
@@ -280,12 +779,22 @@ function exportCSV(todos) {
280
779
 
281
780
  function exportMarkdown(todos) {
282
781
  if (todos.length === 0) return "_(no todos)_";
283
- const lines = ["| ID | Status | Priority | Category | Tags | Due | Title |",
284
- "|----|--------|----------|----------|------|-----|-------|"];
782
+ const lines = ["| ID | Status | Priority | Category | Tags | Due | Assignee | Title |",
783
+ "|----|--------|----------|----------|------|-----|----------|-------|"];
285
784
  for (const t of todos) {
785
+ migrate(t);
286
786
  lines.push(
287
- `| ${t.id} | ${t.done ? "✓" : "○"} | ${t.priority} | ${t.category || "-"} | ${t.tags.join(", ") || "-"} | ${t.due || "-"} | ${t.title} |`
787
+ `| ${t.id} | ${t.done ? "✓" : "○"} | ${t.priority} | ${t.category || "-"} | ${t.tags.join(", ") || "-"} | ${t.due || "-"} | ${t.assignee || "-"} | ${t.title} |`
288
788
  );
289
789
  }
290
790
  return lines.join("\n");
291
791
  }
792
+
793
+ /** Format minutes as "Xh Ym". */
794
+ export function formatTime(minutes) {
795
+ const m = Math.round(Number(minutes) || 0);
796
+ if (m < 60) return `${m}m`;
797
+ const h = Math.floor(m / 60);
798
+ const rem = m % 60;
799
+ return rem ? `${h}h ${rem}m` : `${h}h`;
800
+ }