Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 19 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,25 @@ program
.option("-u, --urgency <int>", "how urgent this task is(0-10)")
.option("-i, --importance <int>", "how important this task is(0-10)")
.option("-et, --estimatedTime <int>", "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")
Expand Down
31 changes: 27 additions & 4 deletions src/render/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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",
Expand Down
42 changes: 33 additions & 9 deletions src/tasks/taskApi.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/tasks/taskCommands.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/types/task.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -41,4 +43,6 @@ export interface IUpdateTask {
dueDate?: Date;
completed?: boolean;
status?: TaskStatus;
startedAt?: Date;
completedAt?: Date;
}
115 changes: 115 additions & 0 deletions tests/tasks/taskApi.class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
35 changes: 35 additions & 0 deletions tests/tasks/taskCommands.class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -81,6 +115,7 @@ describe('TaskCommands', () => {

expect(mockTaskApi.update).toHaveBeenCalledWith('1', {
status: TaskStatus.InProgress,
startedAt: expect.any(Date),
});
expect(render.successEdit).toHaveBeenCalledWith('1');
});
Expand Down