diff --git a/src/render/index.ts b/src/render/index.ts index 24c459c..d6e745b 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -27,10 +27,10 @@ class Render { }; return log(titleObj); } - _buildPrefix(item: { _id: string; [key: string]: any }) { + _buildPrefix(item: { externalId: number; [key: string]: any }) { const prefix = []; - const { _id: id } = item; + const { externalId: id } = item; prefix.push(" ".repeat(4 - String(id).length)); prefix.push(grey(`${id}.`)); @@ -194,7 +194,7 @@ class Render { success({ prefix, message, suffix }); } successGet(task: ITask) { - const [prefix, suffix] = ["\n", `${grey(task._id)}\n`]; + const [prefix, suffix] = ["\n", `${grey(task.externalId)}\n`]; const message = "Get task:"; log({ prefix, message, suffix }); log(grey(JSON.stringify(task, null, 4))); diff --git a/src/tasks/taskApi.class.ts b/src/tasks/taskApi.class.ts index 1e429ee..6e4c326 100644 --- a/src/tasks/taskApi.class.ts +++ b/src/tasks/taskApi.class.ts @@ -8,14 +8,44 @@ import { export class TaskApi { db: any; + constructor(db) { this.db = db; + this.ensureExternalIds(); } - _getById(tasks: ITask[], id: string): ITask { - const task = tasks.find((task) => task._id === id); + ensureExternalIds() { + const tasks = this.getTasks(); + if (tasks.length === 0) return; + let nextId: number = tasks[0].externalId || 0; + tasks.forEach((task: ITask) => { + if (task.externalId === undefined || task.externalId === null) { + nextId += 1; + task.externalId = nextId; + this.db.data.lastTaskId = nextId; + } else { + nextId = task.externalId; + } + }); + } + getTasks(): ITask[] { + return this.db.data.tasks; + } + _getByExternalId(tasks: ITask[], externalId: number): ITask { + const task = tasks.find((task) => task.externalId === externalId); if (task === undefined) throw new Error("Task not found"); return task; } + _getById(tasks: ITask[], id: string): ITask { + const externalId = this.parseExternalId(id); + return this._getByExternalId(tasks, externalId); + } + parseExternalId(id: string): number { + const parsed = parseInt(id, 10); + if (isNaN(parsed) || parsed <= 0) { + throw new Error("Invalid task ID: must be a positive integer"); + } + return parsed; + } async get(id: string): Promise { const tasks = await this.db.data.tasks; return this._getById(tasks, id); @@ -60,14 +90,13 @@ export class TaskApi { }; } async create(data: ICreateTask): Promise { - const tasks = await this.db.data.tasks; + const tasks: ITask[] = await this.getTasks(); const now = new Date(); - const id = `${ - this.db.data.lastTaskId !== undefined ? this.db.data.lastTaskId + 1 : 1 - }`; + const externalId = this.db.data.lastTaskId + 1; const task: ITask = { ...data, - _id: id, + _id: crypto.randomUUID(), + externalId, priority: calculatePriority(data), createdAt: now, updatedAt: now, @@ -76,7 +105,7 @@ export class TaskApi { timeSpent: 0, }; tasks.push(task); - this.db.data.lastTaskId = parseInt(id); + this.db.data.lastTaskId = externalId; await this.db.write(); return task; } @@ -118,7 +147,7 @@ export class TaskApi { // Recalculate priority task = { ...task, priority: calculatePriority(task as any) }; // Update the task in the array - const index = tasks.findIndex((t: ITask) => t._id === id); + const index = tasks.findIndex((t: ITask) => t.externalId === task.externalId); if (index !== -1) { tasks[index] = task; } @@ -127,8 +156,9 @@ export class TaskApi { return task; } async delete(id: string): Promise { + const externalId = this.parseExternalId(id); const tasks = await this.db.data.tasks; - const index = tasks.findIndex((task: ITask) => task._id === id); + const index = tasks.findIndex((task: ITask) => task.externalId === externalId); if (index === -1) { throw new Error("Task not found"); } diff --git a/src/tasks/taskCommands.class.ts b/src/tasks/taskCommands.class.ts index b8d0ca0..f27692e 100644 --- a/src/tasks/taskCommands.class.ts +++ b/src/tasks/taskCommands.class.ts @@ -46,7 +46,7 @@ export default class TaskCommands { async createTask(task: ICreateTask): Promise { const taskCreated = await this.taskAPi.create(task); - render.successCreate(taskCreated._id); + render.successCreate(String(taskCreated.externalId)); } async getTask(id: string): Promise { const task = await this.taskAPi.get(id); @@ -56,20 +56,20 @@ export default class TaskCommands { async updateTask(id: string, data: IUpdateTask): Promise { const task = await this.taskAPi.update(id, data); - render.successEdit(task._id); + render.successEdit(String(task.externalId)); } async setStatusInProgress(id: string) { const task = await this.taskAPi.update(id, { status: TaskStatus.InProgress, startedAt: new Date(), }); - render.successEdit(task._id); + render.successEdit(String(task.externalId)); } async setStatusBlocked(id: string) { const task = await this.taskAPi.update(id, { status: TaskStatus.Blocked, }); - render.successEdit(task._id); + render.successEdit(String(task.externalId)); } /** * @@ -86,19 +86,18 @@ export default class TaskCommands { const task = await this.taskAPi.update(id, { status, }); - render.successEdit(task._id); + render.successEdit(String(task.externalId)); } /** * Archive all tasks that have been completed */ async clearCompletedTasks() { - // Get all tasks const tasks = await this.taskAPi.getAll(); tasks.data.forEach(async (task: ITask) => { if (task.status === TaskStatus.Done) { this.archivedb.data.tasks.push(task); this.db.data.tasks.splice( - this.db.data.tasks.findIndex((t: ITask) => t._id === task._id), + this.db.data.tasks.findIndex((t: ITask) => t.externalId === task.externalId), 1 ); } @@ -114,11 +113,10 @@ export default class TaskCommands { const tasks = await this.taskAPi.getAll(); let newId = 1; tasks.data.forEach(async (task: ITask) => { - task._id = `${newId}`; + task.externalId = newId; newId += 1; }); - // Update the lastTaskId so new tasks use lastTaskId +1 ... - this.db.data.lastTaskId = newId; + this.db.data.lastTaskId = newId - 1; await this.db.write(); } diff --git a/src/types/task.interface.ts b/src/types/task.interface.ts index 62e3e3d..54fdd25 100644 --- a/src/types/task.interface.ts +++ b/src/types/task.interface.ts @@ -1,5 +1,6 @@ export interface ITask { _id: string; + externalId: number; title: string; description: string; status: TaskStatus; diff --git a/tests/tasks/taskApi.class.test.ts b/tests/tasks/taskApi.class.test.ts index 488a5e4..ea8583c 100644 --- a/tests/tasks/taskApi.class.test.ts +++ b/tests/tasks/taskApi.class.test.ts @@ -11,7 +11,8 @@ describe('TaskApi', () => { data: { tasks: [ { - _id: '1', + _id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + externalId: 1, title: 'Task 1', importance: 3, urgency: 3, @@ -22,7 +23,8 @@ describe('TaskApi', () => { labels: [] }, { - _id: '2', + _id: 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + externalId: 2, title: 'Task 2', importance: 1, urgency: 1, @@ -41,9 +43,10 @@ describe('TaskApi', () => { }); describe('get', () => { - it('should return a task by id', async () => { + it('should return a task by externalId', async () => { const task = await taskApi.get('1'); - expect(task._id).toBe('1'); + expect(task._id).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + expect(task.externalId).toBe(1); expect(task.title).toBe('Task 1'); }); @@ -60,18 +63,56 @@ describe('TaskApi', () => { it('should apply limit and offset', async () => { const result = await taskApi.getAll({ limit: 1, offset: 1 }); - expect(result.data[0]._id).toBe('2'); + expect(result.data[0].externalId).toBe(2); }); it('should apply sorting', async () => { const result = await taskApi.getAll({ sort: 'importance' as any, order: 'asc' }); - expect(result.data[0]._id).toBe('2'); + expect(result.data[0].externalId).toBe(2); }); it('should apply filtering by statusExcludeFilter', async () => { const result = await taskApi.getAll({ statusExcludeFilter: [TaskStatus.Done] }); expect(result.data.length).toBe(1); - expect(result.data[0]._id).toBe('1'); + expect(result.data[0].externalId).toBe(1); + }); + + it('should exclude blocked tasks when hideBlockedTasks is true', async () => { + mockDb.data = { + tasks: [ + { + _id: 'c3d4e5f6-a7b8-9012-cdef-123456789012', + externalId: 1, + title: 'Task 1', + importance: 5, + urgency: 5, + estimatedTime: 1, + status: TaskStatus.Pending, + createdAt: new Date(), + updatedAt: new Date(), + labels: [], + timeSpent: 0, + }, + { + _id: 'd4e5f6a7-b8c9-0123-defa-234567890123', + externalId: 2, + title: 'Task 2', + importance: 1, + urgency: 1, + estimatedTime: 1, + status: TaskStatus.Blocked, + createdAt: new Date(), + updatedAt: new Date(), + labels: [], + timeSpent: 0, + }, + ], + lastTaskId: 2, + }; + taskApi = new TaskApi(mockDb); + const result = await taskApi.getAll({ hideBlockedTasks: true }); + expect(result.data.length).toBe(1); + expect(result.data[0].externalId).toBe(1); }); }); @@ -85,7 +126,7 @@ describe('TaskApi', () => { labels: ['label1'] }; const task = await taskApi.create(newTaskData as any); - expect(task._id).toBe('3'); + expect(task.externalId).toBe(3); expect(task.title).toBe('New Task'); expect(task.status).toBe(TaskStatus.Pending); expect(mockDb.data.lastTaskId).toBe(3); @@ -128,7 +169,8 @@ describe('TaskApi', () => { mockDb.data = { tasks: [ { - _id: '1', + _id: '12345678-1234-5678-1234-567812345678', + externalId: 1, title: 'Task with time tracking', importance: 3, urgency: 3, diff --git a/tests/tasks/taskCommands.class.test.ts b/tests/tasks/taskCommands.class.test.ts index 95c5dd0..dfcf6d0 100644 --- a/tests/tasks/taskCommands.class.test.ts +++ b/tests/tasks/taskCommands.class.test.ts @@ -37,7 +37,7 @@ describe('TaskCommands', () => { describe('createTask', () => { it('should create a task and call render.successCreate', async () => { const taskData = { title: 'New Task', importance: 1, urgency: 1, estimatedTime: 1 }; - const createdTask = { _id: '1', ...taskData }; + const createdTask = { externalId: 1, ...taskData }; mockTaskApi.create.mockResolvedValue(createdTask); await taskCommands.createTask(taskData as any); @@ -47,8 +47,8 @@ describe('TaskCommands', () => { }); describe('createTask with CLI positional arguments', () => { it('should correctly parse positional args from k create "test" "" 5 10 45', async () => { - const createdTask = { _id: '1', title: 'test', estimatedTime: 45, urgency: 5, importance: 10 }; - mockTaskApi.create.mockResolvedValue(createdTask); + const createdTask = { externalId: 1, title: 'test', estimatedTime: 45, urgency: 5, importance: 10 }; + mockTaskApi.create.mockResolvedValue(createdTask); // Simulate current CLI handler receiving args from commander const [title, posDescription, posUrgency, posImportance, posEstimatedTime] = @@ -83,7 +83,7 @@ describe('TaskCommands', () => { describe('getTask', () => { it('should get a task and call render.successGet', async () => { - const task = { _id: '1', title: 'Task 1' }; + const task = { externalId: 1, title: 'Task 1' }; mockTaskApi.get.mockResolvedValue(task); await taskCommands.getTask('1'); @@ -96,7 +96,7 @@ describe('TaskCommands', () => { describe('updateTask', () => { it('should update a task and call render.successEdit', async () => { const updateData = { title: 'Updated Task' }; - const updatedTask = { _id: '1', ...updateData }; + const updatedTask = { externalId: 1, ...updateData }; mockTaskApi.update.mockResolvedValue(updatedTask); await taskCommands.updateTask('1', updateData as any); @@ -108,7 +108,7 @@ describe('TaskCommands', () => { describe('setStatusInProgress', () => { it('should set task status to InProgress and call render.successEdit', async () => { - const updatedTask = { _id: '1', status: TaskStatus.InProgress }; + const updatedTask = { externalId: 1, status: TaskStatus.InProgress }; mockTaskApi.update.mockResolvedValue(updatedTask); await taskCommands.setStatusInProgress('1'); @@ -123,9 +123,9 @@ describe('TaskCommands', () => { describe('checkStatus', () => { it('should toggle status from Done to Pending', async () => { - const task = { _id: '1', status: TaskStatus.Done }; + const task = { externalId: 1, status: TaskStatus.Done }; mockTaskApi.get.mockResolvedValue(task); - mockTaskApi.update.mockResolvedValue({ _id: '1', status: TaskStatus.Pending }); + mockTaskApi.update.mockResolvedValue({ externalId: 1, status: TaskStatus.Pending }); await taskCommands.checkStatus('1'); @@ -137,9 +137,9 @@ describe('TaskCommands', () => { }); it('should toggle status from Pending to Done', async () => { - const task = { _id: '1', status: TaskStatus.Pending }; + const task = { externalId: 1, status: TaskStatus.Pending }; mockTaskApi.get.mockResolvedValue(task); - mockTaskApi.update.mockResolvedValue({ _id: '1', status: TaskStatus.Done }); + mockTaskApi.update.mockResolvedValue({ externalId: 1, status: TaskStatus.Done }); await taskCommands.checkStatus('1'); @@ -153,7 +153,7 @@ describe('TaskCommands', () => { describe('showTasksDashboard', () => { it('should show tasks dashboard with default params and call render.displayTaskDashboard', async () => { - const tasks = { data: [{ _id: '1', title: 'Task 1' }] }; + const tasks = { data: [{ externalId: 1, title: 'Task 1' }] }; mockTaskApi.getAll.mockResolvedValue(tasks); await taskCommands.showTasksDashboard(); @@ -163,7 +163,7 @@ describe('TaskCommands', () => { }); it('should show tasks dashboard with filter when hideBlockedTasks is true', async () => { - const tasks = { data: [{ _id: '1', title: 'Task 1' }] }; + const tasks = { data: [{ externalId: 1, title: 'Task 1' }] }; mockTaskApi.getAll.mockResolvedValue(tasks); await taskCommands.showTasksDashboard(true); @@ -177,7 +177,7 @@ describe('TaskCommands', () => { describe('setStatusBlocked', () => { it('should set task status to Blocked and call render.successEdit', async () => { - const updatedTask = { _id: '1', status: TaskStatus.Blocked }; + const updatedTask = { externalId: 1, status: TaskStatus.Blocked }; mockTaskApi.update.mockResolvedValue(updatedTask); await taskCommands.setStatusBlocked('1'); @@ -188,4 +188,55 @@ describe('TaskCommands', () => { expect(render.successEdit).toHaveBeenCalledWith('1'); }); }); + + describe('deleteTask', () => { + it('should delete a task and call render.successDelete', async () => { + mockTaskApi.delete.mockResolvedValue(undefined); + + await taskCommands.deleteTask('1'); + + expect(mockTaskApi.delete).toHaveBeenCalledWith('1'); + expect(render.successDelete).toHaveBeenCalledWith('1'); + }); + }); + + describe('showAgendaDashboard', () => { + it('should show agenda dashboard and call render.displayAgendaDashboard', async () => { + const tasks = { data: [{ externalId: 1, title: 'Task 1' }] }; + mockTaskApi.getAll.mockResolvedValue(tasks); + + await taskCommands.showAgendaDashboard(); + + expect(mockTaskApi.getAll).toHaveBeenCalledWith({ + statusExcludeFilter: [TaskStatus.Blocked, TaskStatus.Done], + }); + expect(render.displayAgendaDashboard).toHaveBeenCalledWith(tasks.data); + }); + }); + + describe('clearCompletedTasks', () => { + it('should move completed tasks to archive', async () => { + const doneTask = { _id: '1', title: 'Done Task', status: TaskStatus.Pending, externalId: 1 }; + const completedTask = { _id: '2', title: 'Completed', status: TaskStatus.Done, externalId: 2 }; + mockTaskApi.getAll.mockResolvedValue({ data: [doneTask, completedTask] }); + + await taskCommands.clearCompletedTasks(); + + expect(taskCommands.archivedb.data.tasks).toContain(completedTask); + }); + }); + + describe('reindexTasks', () => { + it('should reassign sequential externalIds', async () => { + const task1 = { _id: 'uuid-1', externalId: 10 }; + const task2 = { _id: 'uuid-2', externalId: 20 }; + mockTaskApi.getAll.mockResolvedValue({ data: [task1, task2] }); + + await taskCommands.reindexTasks(); + + expect(task1.externalId).toBe(1); + expect(task2.externalId).toBe(2); + expect(taskCommands.db.data.lastTaskId).toBe(2); + }); + }); });