@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/README.md +200 -144
- package/README.zh-CN.md +150 -153
- package/package.json +19 -6
- package/src/cli.js +396 -24
- package/src/server.js +422 -0
- package/src/todo.js +544 -35
- package/web/css/style.css +804 -0
- package/web/index.html +216 -0
- package/web/js/app.js +934 -0
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, {
|
|
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, {
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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]
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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
|
|
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) ?
|
|
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
|
+
}
|