fix(todo-backend): implement recurrence handling and fix N+1 query

- Implement recurring task handling using rrule library
  - createNextOccurrence() creates next task instance when completing recurring task
  - calculateNextStartDate() maintains offset between start/due dates
  - Copies labels, subtasks (reset), and metadata to new occurrence
  - Respects recurrenceEndDate limit

- Fix N+1 query problem for task labels
  - Replace individual loadTaskLabels() calls with batch loadTaskLabelsBatch()
  - Reduces database queries from O(2n) to O(2) for task lists
  - Uses Maps for O(1) lookups when combining tasks with labels

- Add Jest test coverage (25 tests)
  - CRUD operations, task status changes, recurrence
  - Special queries (inbox, today, completed)
  - Batch loading efficiency verification

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 14:11:17 +01:00 committed by Wuesteon
parent 59324cae1c
commit d8f1bbbbce
5 changed files with 1123 additions and 248 deletions

View file

@ -0,0 +1,16 @@
/** @type {import('jest').Config} */
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@todo/shared$': '<rootDir>/../../packages/shared/src',
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
},
};

View file

@ -9,33 +9,41 @@
"start": "nest start",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@todo/shared": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.9",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.9",
"@nestjs/platform-express": "^10.4.9",
"@nestjs/schedule": "^4.1.2",
"@todo/shared": "workspace:*",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rrule": "^2.8.1",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^11.1.9",
"@types/express": "^5.0.1",
"@types/jest": "^30.0.0",
"@types/node": "^22.15.21",
"drizzle-kit": "^0.30.2",
"jest": "^30.2.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.4",
"typescript": "^5.9.3"
}

View file

@ -0,0 +1,480 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { TaskService } from '../task.service';
import { ProjectService } from '../../project/project.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
// Mock database
const mockDb = {
query: {
tasks: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
taskLabels: {
findMany: jest.fn(),
},
labels: {
findMany: jest.fn(),
},
},
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn(),
};
// Mock ProjectService
const mockProjectService = {
findByIdOrThrow: jest.fn(),
};
describe('TaskService', () => {
let service: TaskService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TaskService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
{
provide: ProjectService,
useValue: mockProjectService,
},
],
}).compile();
service = module.get<TaskService>(TaskService);
// Reset all mocks before each test
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findAll', () => {
it('should return all tasks for a user', async () => {
const userId = 'user-123';
const mockTasks = [
{ id: 'task-1', title: 'Task 1', userId },
{ id: 'task-2', title: 'Task 2', userId },
];
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.findAll(userId);
expect(result).toHaveLength(2);
expect(result[0].labels).toEqual([]);
expect(result[1].labels).toEqual([]);
});
it('should filter by projectId when provided', async () => {
const userId = 'user-123';
const projectId = 'project-1';
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
await service.findAll(userId, { projectId });
expect(mockDb.query.tasks.findMany).toHaveBeenCalled();
});
it('should filter by priority when provided', async () => {
const userId = 'user-123';
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
await service.findAll(userId, { priority: 'high' });
expect(mockDb.query.tasks.findMany).toHaveBeenCalled();
});
});
describe('findById', () => {
it('should return a task when found', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const mockTask = { id: taskId, title: 'Test Task', userId };
mockDb.query.tasks.findFirst.mockResolvedValue(mockTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.findById(taskId, userId);
expect(result).toBeDefined();
expect(result?.id).toBe(taskId);
expect(result?.labels).toEqual([]);
});
it('should return null when task not found', async () => {
mockDb.query.tasks.findFirst.mockResolvedValue(null);
const result = await service.findById('non-existent', 'user-123');
expect(result).toBeNull();
});
});
describe('findByIdOrThrow', () => {
it('should return a task when found', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const mockTask = { id: taskId, title: 'Test Task', userId };
mockDb.query.tasks.findFirst.mockResolvedValue(mockTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.findByIdOrThrow(taskId, userId);
expect(result.id).toBe(taskId);
});
it('should throw NotFoundException when task not found', async () => {
mockDb.query.tasks.findFirst.mockResolvedValue(null);
await expect(service.findByIdOrThrow('non-existent', 'user-123')).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
it('should create a task with basic fields', async () => {
const userId = 'user-123';
const dto = { title: 'New Task' };
const createdTask = { id: 'task-new', title: 'New Task', userId, order: 0 };
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([createdTask]);
const result = await service.create(userId, dto);
expect(result.title).toBe('New Task');
expect(mockDb.insert).toHaveBeenCalled();
});
it('should verify project belongs to user when projectId is provided', async () => {
const userId = 'user-123';
const projectId = 'project-1';
const dto = { title: 'New Task', projectId };
const createdTask = { id: 'task-new', title: 'New Task', userId, projectId, order: 0 };
mockProjectService.findByIdOrThrow.mockResolvedValue({ id: projectId, userId });
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([createdTask]);
await service.create(userId, dto);
expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(projectId, userId);
});
it('should calculate order based on existing tasks', async () => {
const userId = 'user-123';
const dto = { title: 'New Task' };
const existingTasks = [
{ id: 'task-1', order: 0 },
{ id: 'task-2', order: 1 },
{ id: 'task-3', order: 2 },
];
const createdTask = { id: 'task-new', title: 'New Task', userId, order: 3 };
mockDb.query.tasks.findMany.mockResolvedValue(existingTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([createdTask]);
const result = await service.create(userId, dto);
expect(result.order).toBe(3);
});
});
describe('update', () => {
it('should update a task', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const dto = { title: 'Updated Title' };
const existingTask = { id: taskId, title: 'Original', userId };
const updatedTask = { id: taskId, title: 'Updated Title', userId };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([updatedTask]);
const result = await service.update(taskId, userId, dto);
expect(result.title).toBe('Updated Title');
});
it('should throw when task does not exist', async () => {
mockDb.query.tasks.findFirst.mockResolvedValue(null);
await expect(service.update('non-existent', 'user-123', { title: 'Test' })).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete a task', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const existingTask = { id: taskId, userId };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
await service.delete(taskId, userId);
expect(mockDb.delete).toHaveBeenCalled();
});
it('should throw when task does not exist', async () => {
mockDb.query.tasks.findFirst.mockResolvedValue(null);
await expect(service.delete('non-existent', 'user-123')).rejects.toThrow(NotFoundException);
});
});
describe('complete', () => {
it('should mark a task as completed', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const existingTask = { id: taskId, title: 'Test', userId, recurrenceRule: null };
const completedTask = { ...existingTask, isCompleted: true, status: 'completed' };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([completedTask]);
const result = await service.complete(taskId, userId);
expect(result.isCompleted).toBe(true);
expect(result.status).toBe('completed');
});
it('should create next occurrence for recurring task', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const existingTask = {
id: taskId,
title: 'Daily Task',
userId,
recurrenceRule: 'FREQ=DAILY',
dueDate: new Date(),
labels: [],
};
const completedTask = {
...existingTask,
isCompleted: true,
status: 'completed',
completedAt: new Date(),
lastOccurrence: new Date(),
};
const newTask = {
id: 'task-new',
title: 'Daily Task',
userId,
recurrenceRule: 'FREQ=DAILY',
dueDate: tomorrow,
isCompleted: false,
status: 'pending',
};
// First call for findByIdOrThrow
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
// For completing the task
mockDb.returning
.mockResolvedValueOnce([newTask]) // For creating new occurrence
.mockResolvedValueOnce([completedTask]); // For completing original
const result = await service.complete(taskId, userId);
expect(result.isCompleted).toBe(true);
// Verify that a new task was created
expect(mockDb.insert).toHaveBeenCalled();
});
});
describe('uncomplete', () => {
it('should mark a task as not completed', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const existingTask = { id: taskId, title: 'Test', userId, isCompleted: true };
const uncompletedTask = { ...existingTask, isCompleted: false, status: 'pending' };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([uncompletedTask]);
const result = await service.uncomplete(taskId, userId);
expect(result.isCompleted).toBe(false);
expect(result.status).toBe('pending');
});
});
describe('move', () => {
it('should move a task to a different project', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const newProjectId = 'project-2';
const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' };
const movedTask = { ...existingTask, projectId: newProjectId };
mockProjectService.findByIdOrThrow.mockResolvedValue({ id: newProjectId, userId });
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([movedTask]);
const result = await service.move(taskId, userId, newProjectId);
expect(result.projectId).toBe(newProjectId);
expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(newProjectId, userId);
});
it('should move a task to inbox (null project)', async () => {
const userId = 'user-123';
const taskId = 'task-1';
const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' };
const movedTask = { ...existingTask, projectId: null };
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
mockDb.query.tasks.findMany.mockResolvedValue([]);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([movedTask]);
const result = await service.move(taskId, userId, null);
expect(result.projectId).toBeNull();
expect(mockProjectService.findByIdOrThrow).not.toHaveBeenCalled();
});
});
describe('getInboxTasks', () => {
it('should return incomplete tasks', async () => {
const userId = 'user-123';
const mockTasks = [
{ id: 'task-1', title: 'Task 1', userId, isCompleted: false },
{ id: 'task-2', title: 'Task 2', userId, isCompleted: false },
];
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.getInboxTasks(userId);
expect(result).toHaveLength(2);
expect(result.every((t) => t.isCompleted === false)).toBe(true);
});
});
describe('getTodayTasks', () => {
it('should return tasks due today', async () => {
const userId = 'user-123';
const today = new Date();
const mockTasks = [{ id: 'task-1', title: 'Today Task', userId, dueDate: today }];
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.getTodayTasks(userId);
expect(result).toHaveLength(1);
});
});
describe('getCompletedTasks', () => {
it('should return completed tasks with default limit', async () => {
const userId = 'user-123';
const mockTasks = Array(50)
.fill(null)
.map((_, i) => ({
id: `task-${i}`,
title: `Task ${i}`,
userId,
isCompleted: true,
}));
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.getCompletedTasks(userId);
expect(result).toHaveLength(50);
});
it('should respect custom limit', async () => {
const userId = 'user-123';
const mockTasks = Array(10)
.fill(null)
.map((_, i) => ({
id: `task-${i}`,
title: `Task ${i}`,
userId,
isCompleted: true,
}));
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
const result = await service.getCompletedTasks(userId, 10);
expect(result).toHaveLength(10);
});
});
describe('loadTaskLabelsBatch', () => {
it('should batch load labels for multiple tasks', async () => {
const userId = 'user-123';
const mockTasks = [
{ id: 'task-1', title: 'Task 1', userId },
{ id: 'task-2', title: 'Task 2', userId },
];
const mockTaskLabels = [
{ taskId: 'task-1', labelId: 'label-1' },
{ taskId: 'task-1', labelId: 'label-2' },
{ taskId: 'task-2', labelId: 'label-1' },
];
const mockLabels = [
{ id: 'label-1', name: 'Important', color: '#ff0000' },
{ id: 'label-2', name: 'Work', color: '#0000ff' },
];
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
mockDb.query.taskLabels.findMany.mockResolvedValue(mockTaskLabels);
mockDb.query.labels.findMany.mockResolvedValue(mockLabels);
const result = await service.findAll(userId);
expect(result[0].labels).toHaveLength(2);
expect(result[1].labels).toHaveLength(1);
// Should only make 2 queries for labels (taskLabels + labels), not N+1
expect(mockDb.query.taskLabels.findMany).toHaveBeenCalledTimes(1);
expect(mockDb.query.labels.findMany).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -1,11 +1,15 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm';
import { RRule, RRuleSet, rrulestr } from 'rrule';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { tasks, taskLabels, labels, type Task, type NewTask, type Subtask } from '../db/schema';
import { ProjectService } from '../project/project.service';
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
// Extended Task type that includes labels (populated after loading from DB)
type TaskWithLabels = Task & { labels: (typeof labels.$inferSelect)[] };
@Injectable()
export class TaskService {
constructor(
@ -13,7 +17,7 @@ export class TaskService {
private projectService: ProjectService
) {}
async findAll(userId: string, query: QueryTasksDto = {}): Promise<Task[]> {
async findAll(userId: string, query: QueryTasksDto = {}): Promise<TaskWithLabels[]> {
const conditions: SQL[] = [eq(tasks.userId, userId)];
if (query.projectId) {
@ -73,11 +77,11 @@ export class TaskService {
offset: query.offset,
});
// Load labels for each task
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
// Batch load labels for all tasks (2 queries instead of N+1)
return this.loadTaskLabelsBatch(result);
}
async findById(id: string, userId: string): Promise<Task | null> {
async findById(id: string, userId: string): Promise<TaskWithLabels | null> {
const result = await this.db.query.tasks.findFirst({
where: and(eq(tasks.id, id), eq(tasks.userId, userId)),
});
@ -86,7 +90,7 @@ export class TaskService {
return this.loadTaskLabels(result);
}
async findByIdOrThrow(id: string, userId: string): Promise<Task> {
async findByIdOrThrow(id: string, userId: string): Promise<TaskWithLabels> {
const task = await this.findById(id, userId);
if (!task) {
throw new NotFoundException(`Task with id ${id} not found`);
@ -94,7 +98,7 @@ export class TaskService {
return task;
}
async create(userId: string, dto: CreateTaskDto): Promise<Task> {
async create(userId: string, dto: CreateTaskDto): Promise<TaskWithLabels> {
// Verify project belongs to user if provided
if (dto.projectId) {
await this.projectService.findByIdOrThrow(dto.projectId, userId);
@ -139,7 +143,7 @@ export class TaskService {
return this.loadTaskLabels(created);
}
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<Task> {
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<TaskWithLabels> {
await this.findByIdOrThrow(id, userId);
// Verify project belongs to user if changing project
@ -185,13 +189,28 @@ export class TaskService {
await this.db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
}
async complete(id: string, userId: string): Promise<Task> {
async complete(id: string, userId: string): Promise<TaskWithLabels> {
const task = await this.findByIdOrThrow(id, userId);
// If task has recurrence, create next occurrence instead of completing
if (task.recurrenceRule) {
// TODO: Implement recurrence handling
// For now, just mark as complete
const nextOccurrence = await this.createNextOccurrence(task, userId);
if (nextOccurrence) {
// Mark current task as completed and update lastOccurrence
const [completed] = await this.db
.update(tasks)
.set({
isCompleted: true,
status: 'completed',
completedAt: new Date(),
lastOccurrence: new Date(),
updatedAt: new Date(),
})
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
.returning();
return this.loadTaskLabels(completed);
}
}
return this.update(id, userId, {
@ -200,14 +219,113 @@ export class TaskService {
});
}
async uncomplete(id: string, userId: string): Promise<Task> {
/**
* Creates the next occurrence of a recurring task based on its RRULE.
* Returns the newly created task, or null if no more occurrences should be created.
*/
private async createNextOccurrence(
task: TaskWithLabels,
userId: string
): Promise<TaskWithLabels | null> {
if (!task.recurrenceRule) return null;
try {
// Parse the RRULE string
const rule = rrulestr(task.recurrenceRule);
const now = new Date();
// Get the next occurrence after now
const nextDate = rule.after(now, false);
// Check if we've exceeded the recurrence end date
if (task.recurrenceEndDate) {
const endDate = new Date(task.recurrenceEndDate);
if (!nextDate || nextDate > endDate) {
return null; // No more occurrences
}
}
if (!nextDate) {
return null; // No more occurrences according to RRULE
}
// Reset subtasks (mark all as incomplete)
const resetSubtasks: Subtask[] | undefined = task.subtasks?.map((s) => ({
...s,
isCompleted: false,
completedAt: null,
}));
// Create new task for the next occurrence
const newTask: NewTask = {
userId,
projectId: task.projectId,
parentTaskId: task.parentTaskId,
title: task.title,
description: task.description,
dueDate: nextDate,
dueTime: task.dueTime,
startDate: task.startDate
? this.calculateNextStartDate(task.startDate, task.dueDate, nextDate)
: null,
priority: task.priority ?? 'medium',
status: 'pending',
isCompleted: false,
recurrenceRule: task.recurrenceRule,
recurrenceEndDate: task.recurrenceEndDate,
subtasks: resetSubtasks,
metadata: task.metadata,
order: task.order,
columnId: task.columnId,
columnOrder: task.columnOrder,
};
const [created] = await this.db.insert(tasks).values(newTask).returning();
// Copy labels from original task
if (task.labels && task.labels.length > 0) {
await this.db.insert(taskLabels).values(
task.labels.map((label) => ({
taskId: created.id,
labelId: label.id,
}))
);
}
return this.loadTaskLabels(created);
} catch (error) {
// If RRULE parsing fails, log and return null
console.error('Failed to parse recurrence rule:', error);
return null;
}
}
/**
* Calculates the new start date based on the offset between original start and due dates.
*/
private calculateNextStartDate(
originalStartDate: Date | string | null,
originalDueDate: Date | string | null,
nextDueDate: Date
): Date | null {
if (!originalStartDate || !originalDueDate) return null;
const start = new Date(originalStartDate);
const due = new Date(originalDueDate);
const diffMs = due.getTime() - start.getTime();
// New start date maintains the same offset from the new due date
return new Date(nextDueDate.getTime() - diffMs);
}
async uncomplete(id: string, userId: string): Promise<TaskWithLabels> {
return this.update(id, userId, {
isCompleted: false,
status: 'pending',
});
}
async move(id: string, userId: string, projectId: string | null): Promise<Task> {
async move(id: string, userId: string, projectId: string | null): Promise<TaskWithLabels> {
// Verify new project if provided
if (projectId) {
await this.projectService.findByIdOrThrow(projectId, userId);
@ -247,11 +365,11 @@ export class TaskService {
}
}
async getInboxTasks(userId: string): Promise<Task[]> {
async getInboxTasks(userId: string): Promise<TaskWithLabels[]> {
return this.findAll(userId, { isCompleted: false });
}
async getTodayTasks(userId: string): Promise<Task[]> {
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
@ -270,10 +388,10 @@ export class TaskService {
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
return this.loadTaskLabelsBatch(result);
}
async getUpcomingTasks(userId: string, days: number = 7): Promise<Task[]> {
async getUpcomingTasks(userId: string, days: number = 7): Promise<TaskWithLabels[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
@ -289,20 +407,24 @@ export class TaskService {
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
return this.loadTaskLabelsBatch(result);
}
async getCompletedTasks(userId: string, limit: number = 50): Promise<Task[]> {
async getCompletedTasks(userId: string, limit: number = 50): Promise<TaskWithLabels[]> {
const result = await this.db.query.tasks.findMany({
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
orderBy: [desc(tasks.completedAt)],
limit,
});
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
return this.loadTaskLabelsBatch(result);
}
async reorder(userId: string, taskIds: string[], projectId?: string | null): Promise<Task[]> {
async reorder(
userId: string,
taskIds: string[],
projectId?: string | null
): Promise<TaskWithLabels[]> {
// Update order for each task
const updates = taskIds.map((id, index) =>
this.db
@ -316,22 +438,66 @@ export class TaskService {
return this.findAll(userId, { projectId: projectId ?? undefined });
}
/**
* Loads labels for a single task (used for single task operations).
* For multiple tasks, use loadTaskLabelsBatch instead.
*/
private async loadTaskLabels(
task: Task
): Promise<Task & { labels: (typeof labels.$inferSelect)[] }> {
const taskLabelRows = await this.db.query.taskLabels.findMany({
where: eq(taskLabels.taskId, task.id),
});
const [result] = await this.loadTaskLabelsBatch([task]);
return result;
}
if (taskLabelRows.length === 0) {
return { ...task, labels: [] };
/**
* Batch loads labels for multiple tasks in just 2 queries (instead of N+1).
* This significantly improves performance when loading task lists.
*/
private async loadTaskLabelsBatch(
taskList: Task[]
): Promise<(Task & { labels: (typeof labels.$inferSelect)[] })[]> {
if (taskList.length === 0) {
return [];
}
const labelIds = taskLabelRows.map((tl) => tl.labelId);
const taskLabelsData = await this.db.query.labels.findMany({
where: or(...labelIds.map((id) => eq(labels.id, id))),
const taskIds = taskList.map((t) => t.id);
// Single query to get all task-label relationships
const allTaskLabels = await this.db.query.taskLabels.findMany({
where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))),
});
return { ...task, labels: taskLabelsData };
if (allTaskLabels.length === 0) {
// No labels for any task - return tasks with empty labels array
return taskList.map((task) => ({ ...task, labels: [] }));
}
// Get unique label IDs
const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))];
// Single query to get all labels
const allLabels = await this.db.query.labels.findMany({
where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))),
});
// Create a map of labelId -> label for fast lookup
const labelMap = new Map(allLabels.map((l) => [l.id, l]));
// Create a map of taskId -> labelIds for fast lookup
const taskLabelMap = new Map<string, string[]>();
for (const tl of allTaskLabels) {
const existing = taskLabelMap.get(tl.taskId) || [];
existing.push(tl.labelId);
taskLabelMap.set(tl.taskId, existing);
}
// Combine tasks with their labels
return taskList.map((task) => {
const labelIds = taskLabelMap.get(task.id) || [];
const taskLabelsData = labelIds
.map((id) => labelMap.get(id))
.filter((l): l is typeof labels.$inferSelect => l !== undefined);
return { ...task, labels: taskLabelsData };
});
}
}