From b1cf8d90a12624b49910b30fa4de038b02db6510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1igo=20Imaz?= Date: Fri, 24 Apr 2026 18:27:53 +0200 Subject: [PATCH] fix: create & add timeSpent --- src/index.ts | 37 ++++---- src/render/index.ts | 31 ++++++- src/tasks/taskApi.class.ts | 42 +++++++-- src/tasks/taskCommands.class.ts | 1 + src/types/task.interface.ts | 4 + tests/tasks/taskApi.class.test.ts | 115 +++++++++++++++++++++++++ tests/tasks/taskCommands.class.test.ts | 35 ++++++++ 7 files changed, 234 insertions(+), 31 deletions(-) diff --git a/src/index.ts b/src/index.ts index ae23cbe..32600ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,24 +68,25 @@ program .option("-u, --urgency ", "how urgent this task is(0-10)") .option("-i, --importance ", "how important this task is(0-10)") .option("-et, --estimatedTime ", "how much time will it take to accomplish it (in min)") - .action(async (args, options) => { - // Positional arguments take precedence over options if both are provided - const [title, posDescription, posUrgency, posImportance, posEstimatedTime] = args; - - const description = options.description ?? posDescription; - const urgency = options.urgency ?? posUrgency; - const importance = options.importance ?? posImportance; - const estimatedTime = options.estimatedTime ?? posEstimatedTime; - - await taskCommands.setUpConfig(); - await taskCommands.createTask({ - title, - description, - urgency: parseInt(urgency), - importance: parseInt(importance), - estimatedTime: parseInt(estimatedTime), - }); - }); + .action( + async ( + title: string, + description: string, + urgency: string, + importance: string, + estimatedTime: string, + options + ) => { + await taskCommands.setUpConfig(); + await taskCommands.createTask({ + title, + description: options.description ?? description, + urgency: parseInt(options.urgency ?? urgency, 10), + importance: parseInt(options.importance ?? importance, 10), + estimatedTime: parseInt(options.estimatedTime ?? estimatedTime, 10), + }); + } + ); program .command("get") .description("Get all info of a task") diff --git a/src/render/index.ts b/src/render/index.ts index 968c423..24c459c 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -70,7 +70,30 @@ class Render { _getEstimatedTime(estimatedTime: number) { return blue(`⌛${estimatedTime}min`); } + _getDuration(timeSpent: number, lastStartedAt?: Date, completedAt?: Date): string { + const parts = []; + + // Handle accumulated timeSpent + if (timeSpent > 0) { + const hours = Math.floor(timeSpent / (1000 * 60 * 60)); + const minutes = Math.floor((timeSpent % (1000 * 60 * 60)) / (1000 * 60)); + const timeStr = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + parts.push(`(Spent:${timeStr})`); + } + + // Handle current active session + if (lastStartedAt) { + const end = completedAt ? new Date(completedAt) : new Date(); + const diffMs = end.getTime() - new Date(lastStartedAt).getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + if (diffMins > 0) { + parts.push(`(Active:${diffMins}m)`); + } + } + return parts.length > 0 ? grey(parts.join(' ')) : ""; + } + displayTaskDashboard({ data, total, @@ -87,14 +110,14 @@ class Render { data.forEach((item) => { const age = this._getAge(new Date(item.createdAt)); const estimatedTime = this._getEstimatedTime(item.estimatedTime); + const duration = this._getDuration(item.timeSpent, item.lastStartedAt, item.completedAt); const prefix = this._buildPrefix(item); const message = this._buildMessage(item); - const suffix = - age.length === 0 ? `${estimatedTime}` : `${estimatedTime} ${age}`; + const suffix = [estimatedTime, age, duration].filter(Boolean).join(' '); const msgObj = { prefix, message, suffix }; - + return item.status === TaskStatus.Done ? success(msgObj) : item.status === TaskStatus.InProgress @@ -108,7 +131,7 @@ class Render { } displayAgendaDashboard(tasks: ITask[]) { // Show a line with the day of the week + day of the month - + const date = new Date(); const day = date.toLocaleDateString("en-US", { weekday: "long", diff --git a/src/tasks/taskApi.class.ts b/src/tasks/taskApi.class.ts index 4bbaf97..1e429ee 100644 --- a/src/tasks/taskApi.class.ts +++ b/src/tasks/taskApi.class.ts @@ -73,6 +73,7 @@ export class TaskApi { updatedAt: now, status: TaskStatus.Pending, labels: data.labels || [], + timeSpent: 0, }; tasks.push(task); this.db.data.lastTaskId = parseInt(id); @@ -83,22 +84,45 @@ export class TaskApi { const currentDate = new Date(); const tasks = await this.db.data.tasks; let task = this._getById(tasks, id); - // Set startedAt amd completedAt dates - if (data.status === TaskStatus.InProgress) { - task = { ...task, startedAt: currentDate }; - } else if (data.status === TaskStatus.Done) { - task = { ...task, completedAt: currentDate }; + const oldStatus = task.status; + const newStatus = data.status; + + // Initialize timeSpent if it doesn't exist (for older tasks) + if (task.timeSpent === undefined) task.timeSpent = 0; + + // Handle status transitions for time tracking + if (newStatus !== undefined && newStatus !== oldStatus) { + if (newStatus === TaskStatus.InProgress) { + task.startedAt = currentDate; + task.lastStartedAt = currentDate; + } else { + // Moving away from InProgress (to Done, Blocked, or Pending) + if (oldStatus === TaskStatus.InProgress && task.lastStartedAt) { + const duration = currentDate.getTime() - task.lastStartedAt.getTime(); + task.timeSpent += duration; + task.lastStartedAt = undefined; + } + + if (newStatus === TaskStatus.Done) { + task.completedAt = currentDate; + } + } } - // Update the task object + + // Update the task object with provided data task = { ...task, ...data, updatedAt: currentDate, }; // Recalculate priority - task = { ...task, priority: calculatePriority(task) }; - // Update the task - tasks[tasks.findIndex((task: ITask) => task._id === id)] = task; + task = { ...task, priority: calculatePriority(task as any) }; + // Update the task in the array + const index = tasks.findIndex((t: ITask) => t._id === id); + if (index !== -1) { + tasks[index] = task; + } + await this.db.write(); return task; } diff --git a/src/tasks/taskCommands.class.ts b/src/tasks/taskCommands.class.ts index 8c00ba3..b8d0ca0 100644 --- a/src/tasks/taskCommands.class.ts +++ b/src/tasks/taskCommands.class.ts @@ -61,6 +61,7 @@ export default class TaskCommands { async setStatusInProgress(id: string) { const task = await this.taskAPi.update(id, { status: TaskStatus.InProgress, + startedAt: new Date(), }); render.successEdit(task._id); } diff --git a/src/types/task.interface.ts b/src/types/task.interface.ts index af27068..62e3e3d 100644 --- a/src/types/task.interface.ts +++ b/src/types/task.interface.ts @@ -14,6 +14,8 @@ export interface ITask { updatedAt: Date; startedAt?: Date; // Date when it was set to in progress completedAt?: Date; // Date when it was set to done + timeSpent: number; // Accumulated time in milliseconds + lastStartedAt?: Date; // Date when the current session began } export enum TaskStatus { Pending = "pending", @@ -41,4 +43,6 @@ export interface IUpdateTask { dueDate?: Date; completed?: boolean; status?: TaskStatus; + startedAt?: Date; + completedAt?: Date; } diff --git a/tests/tasks/taskApi.class.test.ts b/tests/tasks/taskApi.class.test.ts index 612487e..488a5e4 100644 --- a/tests/tasks/taskApi.class.test.ts +++ b/tests/tasks/taskApi.class.test.ts @@ -122,6 +122,121 @@ describe('TaskApi', () => { await expect(taskApi.delete('99')).rejects.toThrow('Task not found'); }); }); + + describe('time tracking', () => { + beforeEach(() => { + mockDb.data = { + tasks: [ + { + _id: '1', + title: 'Task with time tracking', + importance: 3, + urgency: 3, + estimatedTime: 10, + status: TaskStatus.Pending, + createdAt: new Date(), + updatedAt: new Date(), + labels: [], + timeSpent: 0, + }, + ], + lastTaskId: 1, + }; + }); + + it('should initialize timeSpent to 0 for new tasks', async () => { + const newTaskData = { + title: 'New Task', + importance: 2, + urgency: 2, + estimatedTime: 2, + }; + const task = await taskApi.create(newTaskData as any); + expect(task.timeSpent).toBe(0); + }); + + it('should set lastStartedAt when moving to InProgress', async () => { + const task = await taskApi.update('1', { ...taskDto({ status: TaskStatus.InProgress }) }); + expect(task.startedAt).toBeInstanceOf(Date); + expect(task.lastStartedAt).toBeInstanceOf(Date); + }); + + it('should accumulate time when moving away from InProgress to Pending', async () => { + // First, start the task + await taskApi.update('1', { ...taskDto({ status: TaskStatus.InProgress }) }); + + // Get the task to see the lastStartedAt + const task = await taskApi.get('1'); + const lastStartedAt = task.lastStartedAt.getTime(); + + // Simulate time passing by directly setting lastStartedAt to 60 seconds ago + const mockNow = new Date(); + mockDb.data.tasks[0].lastStartedAt = new Date(mockNow.getTime() - 60000); // 60 seconds ago + + // Now move to Pending + const updatedTask = await taskApi.update('1', { ...taskDto({ status: TaskStatus.Pending }) }); + + // timeSpent should now contain approximately 60000ms (1 minute) + const duration = updatedTask.timeSpent; + expect(duration).toBeGreaterThan(58000); + expect(duration).toBeLessThan(62000); + expect(updatedTask.lastStartedAt).toBeUndefined(); + }); + + it('should accumulate time when moving from InProgress to Done', async () => { + // Simulate the task being in InProgress status with a known lastStartedAt (60 seconds ago) + mockDb.data.tasks[0].status = TaskStatus.InProgress; + mockDb.data.tasks[0].lastStartedAt = new Date(new Date().getTime() - 60000); + mockDb.data.tasks[0].timeSpent = 0; + + const updatedTask = await taskApi.update('1', { ...taskDto({ status: TaskStatus.Done }) }); + + expect(updatedTask.completedAt).toBeInstanceOf(Date); + expect(updatedTask.timeSpent).toBeGreaterThan(58000); + expect(updatedTask.timeSpent).toBeLessThan(62000); + expect(updatedTask.lastStartedAt).toBeUndefined(); + }); + + it('should accumulate multiple sessions of time', async () => { + // First session: simulate InProgress that ended + mockDb.data.tasks[0].lastStartedAt = new Date(new Date().getTime() - 60000); // 60s in progress + mockDb.data.tasks[0].status = TaskStatus.InProgress; + await taskApi.update('1', { ...taskDto({ status: TaskStatus.Pending }) }); + + const taskAfterFirstSession = await taskApi.get('1'); + const initialTimeSpent = taskAfterFirstSession.timeSpent; + expect(initialTimeSpent).toBeGreaterThan(55000); + + // Second session: set up InProgress state again + mockDb.data.tasks[0].status = TaskStatus.InProgress; + mockDb.data.tasks[0].lastStartedAt = new Date(new Date().getTime() - 120000); // 120s in progress (simulated) + mockDb.data.tasks[0].timeSpent = initialTimeSpent; + + // End second session + await taskApi.update('1', { ...taskDto({ status: TaskStatus.Done }) }); + + const finalTask = await taskApi.get('1'); + // Should have accumulated from both sessions (at least double the first) + expect(finalTask.timeSpent).toBeGreaterThan(initialTimeSpent * 1.5); + }); + + it('should handle timeSpent for legacy tasks without timeSpent property', async () => { + // Remove timeSpent to simulate legacy task + const legacyTask = mockDb.data.tasks[0]; + delete (legacyTask as any).timeSpent; + + const updatedTask = await taskApi.update('1', { ...taskDto({ status: TaskStatus.InProgress }) }); + expect(updatedTask.timeSpent).toBe(0); // Should initialize to 0 + }); + + it('should not update timeSpent when status doesn\'t change', async () => { + await taskApi.update('1', { ...taskDto({ title: 'Updated Title' }) }); + + const task = await taskApi.get('1'); + expect(task.timeSpent).toBe(0); + expect(task.title).toBe('Updated Title'); + }); + }); }); function taskDto(data: any) { diff --git a/tests/tasks/taskCommands.class.test.ts b/tests/tasks/taskCommands.class.test.ts index 03cab38..95c5dd0 100644 --- a/tests/tasks/taskCommands.class.test.ts +++ b/tests/tasks/taskCommands.class.test.ts @@ -45,6 +45,40 @@ describe('TaskCommands', () => { expect(mockTaskApi.create).toHaveBeenCalledWith(taskData); expect(render.successCreate).toHaveBeenCalledWith('1'); }); + 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); + + // Simulate current CLI handler receiving args from commander + const [title, posDescription, posUrgency, posImportance, posEstimatedTime] = + ['test', '', '5', '10', '45']; + + const urgency = parseInt(posUrgency, 10); + const importance = parseInt(posImportance, 10); + const estimatedTime = parseInt(posEstimatedTime, 10); + + await taskCommands.createTask({ + title, + description: posDescription, + urgency, + importance, + estimatedTime, + }); + + expect(estimatedTime).toBe(45); + expect(urgency).toBe(5); + expect(importance).toBe(10); + expect(mockTaskApi.create).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'test', + estimatedTime: 45, + urgency: 5, + importance: 10, + }) + ); + }); + }); }); describe('getTask', () => { @@ -81,6 +115,7 @@ describe('TaskCommands', () => { expect(mockTaskApi.update).toHaveBeenCalledWith('1', { status: TaskStatus.InProgress, + startedAt: expect.any(Date), }); expect(render.successEdit).toHaveBeenCalledWith('1'); });