diff --git a/apps/api/src/domains/domains.controller.spec.ts b/apps/api/src/domains/domains.controller.spec.ts new file mode 100644 index 0000000..571c596 --- /dev/null +++ b/apps/api/src/domains/domains.controller.spec.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DomainsController } from "./domains.controller"; +import { DomainsService } from "./domains.service"; +import { CreateDomainDto, UpdateDomainDto, QueryDomainsDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { ExecutionContext } from "@nestjs/common"; + +describe("DomainsController", () => { + let controller: DomainsController; + let service: DomainsService; + + const mockDomainsService = { + create: vi.fn(), + findAll: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }; + + const mockAuthGuard = { + canActivate: vi.fn((context: ExecutionContext) => { + const request = context.switchToHttp().getRequest(); + request.user = { + id: "550e8400-e29b-41d4-a716-446655440002", + workspaceId: "550e8400-e29b-41d4-a716-446655440001", + }; + return true; + }), + }; + + const mockWorkspaceGuard = { + canActivate: vi.fn(() => true), + }; + + const mockPermissionGuard = { + canActivate: vi.fn(() => true), + }; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; + const mockDomainId = "550e8400-e29b-41d4-a716-446655440003"; + + const mockDomain = { + id: mockDomainId, + workspaceId: mockWorkspaceId, + name: "Work", + slug: "work", + description: "Work-related tasks and projects", + color: "#3B82F6", + icon: "briefcase", + sortOrder: 0, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockUser = { + id: mockUserId, + email: "test@example.com", + name: "Test User", + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DomainsController], + providers: [ + { + provide: DomainsService, + useValue: mockDomainsService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .overrideGuard(WorkspaceGuard) + .useValue(mockWorkspaceGuard) + .overrideGuard(PermissionGuard) + .useValue(mockPermissionGuard) + .compile(); + + controller = module.get(DomainsController); + service = module.get(DomainsService); + + // Clear all mocks before each test + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("create", () => { + it("should create a domain", async () => { + const createDto: CreateDomainDto = { + name: "Work", + slug: "work", + description: "Work-related tasks", + color: "#3B82F6", + icon: "briefcase", + }; + + mockDomainsService.create.mockResolvedValue(mockDomain); + + const result = await controller.create( + createDto, + mockWorkspaceId, + mockUser + ); + + expect(result).toEqual(mockDomain); + expect(service.create).toHaveBeenCalledWith( + mockWorkspaceId, + mockUserId, + createDto + ); + }); + }); + + describe("findAll", () => { + it("should return paginated domains", async () => { + const query: QueryDomainsDto = { page: 1, limit: 10 }; + const paginatedResult = { + data: [mockDomain], + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }; + + mockDomainsService.findAll.mockResolvedValue(paginatedResult); + + const result = await controller.findAll(query, mockWorkspaceId); + + expect(result).toEqual(paginatedResult); + expect(service.findAll).toHaveBeenCalledWith({ + ...query, + workspaceId: mockWorkspaceId, + }); + }); + + it("should handle search query", async () => { + const query: QueryDomainsDto = { + page: 1, + limit: 10, + search: "work", + }; + + mockDomainsService.findAll.mockResolvedValue({ + data: [mockDomain], + meta: { total: 1, page: 1, limit: 10, totalPages: 1 }, + }); + + await controller.findAll(query, mockWorkspaceId); + + expect(service.findAll).toHaveBeenCalledWith({ + ...query, + workspaceId: mockWorkspaceId, + }); + }); + }); + + describe("findOne", () => { + it("should return a domain by ID", async () => { + mockDomainsService.findOne.mockResolvedValue(mockDomain); + + const result = await controller.findOne(mockDomainId, mockWorkspaceId); + + expect(result).toEqual(mockDomain); + expect(service.findOne).toHaveBeenCalledWith( + mockDomainId, + mockWorkspaceId + ); + }); + }); + + describe("update", () => { + it("should update a domain", async () => { + const updateDto: UpdateDomainDto = { + name: "Updated Work", + color: "#10B981", + }; + + const updatedDomain = { ...mockDomain, ...updateDto }; + mockDomainsService.update.mockResolvedValue(updatedDomain); + + const result = await controller.update( + mockDomainId, + updateDto, + mockWorkspaceId, + mockUser + ); + + expect(result).toEqual(updatedDomain); + expect(service.update).toHaveBeenCalledWith( + mockDomainId, + mockWorkspaceId, + mockUserId, + updateDto + ); + }); + }); + + describe("remove", () => { + it("should delete a domain", async () => { + mockDomainsService.remove.mockResolvedValue(undefined); + + await controller.remove(mockDomainId, mockWorkspaceId, mockUser); + + expect(service.remove).toHaveBeenCalledWith( + mockDomainId, + mockWorkspaceId, + mockUserId + ); + }); + }); +}); diff --git a/apps/api/src/domains/domains.service.spec.ts b/apps/api/src/domains/domains.service.spec.ts new file mode 100644 index 0000000..1edb505 --- /dev/null +++ b/apps/api/src/domains/domains.service.spec.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DomainsService } from "./domains.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { ActivityService } from "../activity/activity.service"; +import { NotFoundException, ConflictException } from "@nestjs/common"; + +describe("DomainsService", () => { + let service: DomainsService; + let prisma: PrismaService; + let activityService: ActivityService; + + const mockPrismaService = { + domain: { + create: vi.fn(), + findMany: vi.fn(), + count: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }; + + const mockActivityService = { + logDomainCreated: vi.fn(), + logDomainUpdated: vi.fn(), + logDomainDeleted: vi.fn(), + }; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; + const mockDomainId = "550e8400-e29b-41d4-a716-446655440003"; + + const mockDomain = { + id: mockDomainId, + workspaceId: mockWorkspaceId, + name: "Work", + slug: "work", + description: "Work-related tasks and projects", + color: "#3B82F6", + icon: "briefcase", + sortOrder: 0, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DomainsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: ActivityService, + useValue: mockActivityService, + }, + ], + }).compile(); + + service = module.get(DomainsService); + prisma = module.get(PrismaService); + activityService = module.get(ActivityService); + + // Clear all mocks before each test + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create a domain and log activity", async () => { + const createDto = { + name: "Work", + slug: "work", + description: "Work-related tasks", + color: "#3B82F6", + icon: "briefcase", + }; + + mockPrismaService.domain.findFirst.mockResolvedValue(null); + mockPrismaService.domain.create.mockResolvedValue(mockDomain); + mockActivityService.logDomainCreated.mockResolvedValue({}); + + const result = await service.create(mockWorkspaceId, mockUserId, createDto); + + expect(result).toEqual(mockDomain); + expect(prisma.domain.findFirst).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + slug: createDto.slug, + }, + }); + expect(prisma.domain.create).toHaveBeenCalledWith({ + data: { + ...createDto, + workspace: { + connect: { id: mockWorkspaceId }, + }, + sortOrder: 0, + metadata: {}, + }, + }); + expect(activityService.logDomainCreated).toHaveBeenCalledWith( + mockWorkspaceId, + mockUserId, + mockDomainId, + { name: mockDomain.name } + ); + }); + + it("should throw ConflictException if slug already exists", async () => { + const createDto = { + name: "Work", + slug: "work", + }; + + mockPrismaService.domain.findFirst.mockResolvedValue(mockDomain); + + await expect( + service.create(mockWorkspaceId, mockUserId, createDto) + ).rejects.toThrow(ConflictException); + expect(prisma.domain.create).not.toHaveBeenCalled(); + }); + + it("should use default values for optional fields", async () => { + const createDto = { + name: "Work", + slug: "work", + }; + + mockPrismaService.domain.findFirst.mockResolvedValue(null); + mockPrismaService.domain.create.mockResolvedValue(mockDomain); + mockActivityService.logDomainCreated.mockResolvedValue({}); + + await service.create(mockWorkspaceId, mockUserId, createDto); + + expect(prisma.domain.create).toHaveBeenCalledWith({ + data: { + name: "Work", + slug: "work", + workspace: { + connect: { id: mockWorkspaceId }, + }, + sortOrder: 0, + metadata: {}, + }, + }); + }); + }); + + describe("findAll", () => { + it("should return paginated domains", async () => { + const query = { workspaceId: mockWorkspaceId, page: 1, limit: 10 }; + const mockDomains = [mockDomain]; + + mockPrismaService.domain.findMany.mockResolvedValue(mockDomains); + mockPrismaService.domain.count.mockResolvedValue(1); + + const result = await service.findAll(query); + + expect(result).toEqual({ + data: mockDomains, + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }); + expect(prisma.domain.findMany).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + orderBy: { sortOrder: "asc" }, + skip: 0, + take: 10, + }); + expect(prisma.domain.count).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + }); + }); + + it("should filter by search term", async () => { + const query = { + workspaceId: mockWorkspaceId, + page: 1, + limit: 10, + search: "work", + }; + + mockPrismaService.domain.findMany.mockResolvedValue([mockDomain]); + mockPrismaService.domain.count.mockResolvedValue(1); + + await service.findAll(query); + + expect(prisma.domain.findMany).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + OR: [ + { name: { contains: "work", mode: "insensitive" } }, + { description: { contains: "work", mode: "insensitive" } }, + ], + }, + orderBy: { sortOrder: "asc" }, + skip: 0, + take: 10, + }); + }); + + it("should use default pagination values", async () => { + const query = { workspaceId: mockWorkspaceId }; + + mockPrismaService.domain.findMany.mockResolvedValue([]); + mockPrismaService.domain.count.mockResolvedValue(0); + + await service.findAll(query); + + expect(prisma.domain.findMany).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + orderBy: { sortOrder: "asc" }, + skip: 0, + take: 50, + }); + }); + + it("should calculate pagination correctly", async () => { + const query = { workspaceId: mockWorkspaceId, page: 3, limit: 20 }; + + mockPrismaService.domain.findMany.mockResolvedValue([]); + mockPrismaService.domain.count.mockResolvedValue(55); + + const result = await service.findAll(query); + + expect(result.meta).toEqual({ + total: 55, + page: 3, + limit: 20, + totalPages: 3, + }); + expect(prisma.domain.findMany).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + orderBy: { sortOrder: "asc" }, + skip: 40, // (3 - 1) * 20 + take: 20, + }); + }); + }); + + describe("findOne", () => { + it("should return a domain by ID", async () => { + mockPrismaService.domain.findUnique.mockResolvedValue(mockDomain); + + const result = await service.findOne(mockDomainId, mockWorkspaceId); + + expect(result).toEqual(mockDomain); + expect(prisma.domain.findUnique).toHaveBeenCalledWith({ + where: { + id: mockDomainId, + workspaceId: mockWorkspaceId, + }, + include: { + _count: { + select: { + tasks: true, + projects: true, + events: true, + ideas: true, + }, + }, + }, + }); + }); + + it("should throw NotFoundException if domain not found", async () => { + mockPrismaService.domain.findUnique.mockResolvedValue(null); + + await expect( + service.findOne(mockDomainId, mockWorkspaceId) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("update", () => { + it("should update a domain and log activity", async () => { + const updateDto = { + name: "Updated Work", + color: "#10B981", + }; + + const updatedDomain = { ...mockDomain, ...updateDto }; + + mockPrismaService.domain.findUnique.mockResolvedValue(mockDomain); + mockPrismaService.domain.findFirst.mockResolvedValue(null); + mockPrismaService.domain.update.mockResolvedValue(updatedDomain); + mockActivityService.logDomainUpdated.mockResolvedValue({}); + + const result = await service.update( + mockDomainId, + mockWorkspaceId, + mockUserId, + updateDto + ); + + expect(result).toEqual(updatedDomain); + expect(prisma.domain.update).toHaveBeenCalledWith({ + where: { + id: mockDomainId, + workspaceId: mockWorkspaceId, + }, + data: updateDto, + }); + expect(activityService.logDomainUpdated).toHaveBeenCalledWith( + mockWorkspaceId, + mockUserId, + mockDomainId, + { changes: updateDto } + ); + }); + + it("should throw NotFoundException if domain not found", async () => { + const updateDto = { name: "Updated Work" }; + + mockPrismaService.domain.findUnique.mockResolvedValue(null); + + await expect( + service.update(mockDomainId, mockWorkspaceId, mockUserId, updateDto) + ).rejects.toThrow(NotFoundException); + expect(prisma.domain.update).not.toHaveBeenCalled(); + }); + + it("should throw ConflictException if slug already exists for another domain", async () => { + const updateDto = { slug: "existing-slug" }; + const anotherDomain = { ...mockDomain, id: "another-id", slug: "existing-slug" }; + + mockPrismaService.domain.findUnique.mockResolvedValue(mockDomain); + mockPrismaService.domain.findFirst.mockResolvedValue(anotherDomain); + + await expect( + service.update(mockDomainId, mockWorkspaceId, mockUserId, updateDto) + ).rejects.toThrow(ConflictException); + expect(prisma.domain.update).not.toHaveBeenCalled(); + }); + + it("should allow updating to the same slug", async () => { + const updateDto = { slug: "work", name: "Updated Work" }; + + mockPrismaService.domain.findUnique.mockResolvedValue(mockDomain); + mockPrismaService.domain.findFirst.mockResolvedValue(mockDomain); + mockPrismaService.domain.update.mockResolvedValue({ ...mockDomain, ...updateDto }); + mockActivityService.logDomainUpdated.mockResolvedValue({}); + + await service.update(mockDomainId, mockWorkspaceId, mockUserId, updateDto); + + expect(prisma.domain.update).toHaveBeenCalled(); + }); + }); + + describe("remove", () => { + it("should delete a domain and log activity", async () => { + mockPrismaService.domain.findUnique.mockResolvedValue(mockDomain); + mockPrismaService.domain.delete.mockResolvedValue(mockDomain); + mockActivityService.logDomainDeleted.mockResolvedValue({}); + + await service.remove(mockDomainId, mockWorkspaceId, mockUserId); + + expect(prisma.domain.delete).toHaveBeenCalledWith({ + where: { + id: mockDomainId, + workspaceId: mockWorkspaceId, + }, + }); + expect(activityService.logDomainDeleted).toHaveBeenCalledWith( + mockWorkspaceId, + mockUserId, + mockDomainId, + { name: mockDomain.name } + ); + }); + + it("should throw NotFoundException if domain not found", async () => { + mockPrismaService.domain.findUnique.mockResolvedValue(null); + + await expect( + service.remove(mockDomainId, mockWorkspaceId, mockUserId) + ).rejects.toThrow(NotFoundException); + expect(prisma.domain.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/events/events.service.spec.ts b/apps/api/src/events/events.service.spec.ts index 5526f57..06d966f 100644 --- a/apps/api/src/events/events.service.spec.ts +++ b/apps/api/src/events/events.service.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { EventsService } from "./events.service"; import { PrismaService } from "../prisma/prisma.service"; import { ActivityService } from "../activity/activity.service"; +import { WebSocketGateway } from "../websocket/websocket.gateway"; import { NotFoundException } from "@nestjs/common"; import { Prisma } from "@prisma/client"; @@ -10,6 +11,7 @@ describe("EventsService", () => { let service: EventsService; let prisma: PrismaService; let activityService: ActivityService; + let wsGateway: WebSocketGateway; const mockPrismaService = { event: { @@ -28,6 +30,12 @@ describe("EventsService", () => { logEventDeleted: vi.fn(), }; + const mockWebSocketGateway = { + emitEventCreated: vi.fn(), + emitEventUpdated: vi.fn(), + emitEventDeleted: vi.fn(), + }; + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; const mockEventId = "550e8400-e29b-41d4-a716-446655440003"; @@ -61,12 +69,17 @@ describe("EventsService", () => { provide: ActivityService, useValue: mockActivityService, }, + { + provide: WebSocketGateway, + useValue: mockWebSocketGateway, + }, ], }).compile(); service = module.get(EventsService); prisma = module.get(PrismaService); activityService = module.get(ActivityService); + wsGateway = module.get(WebSocketGateway); vi.clearAllMocks(); }); diff --git a/apps/api/src/projects/projects.service.spec.ts b/apps/api/src/projects/projects.service.spec.ts index 46a99f2..3de5724 100644 --- a/apps/api/src/projects/projects.service.spec.ts +++ b/apps/api/src/projects/projects.service.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { ProjectsService } from "./projects.service"; import { PrismaService } from "../prisma/prisma.service"; import { ActivityService } from "../activity/activity.service"; +import { WebSocketGateway } from "../websocket/websocket.gateway"; import { ProjectStatus, Prisma } from "@prisma/client"; import { NotFoundException } from "@nestjs/common"; @@ -10,6 +11,7 @@ describe("ProjectsService", () => { let service: ProjectsService; let prisma: PrismaService; let activityService: ActivityService; + let wsGateway: WebSocketGateway; const mockPrismaService = { project: { @@ -28,6 +30,10 @@ describe("ProjectsService", () => { logProjectDeleted: vi.fn(), }; + const mockWebSocketGateway = { + emitProjectUpdated: vi.fn(), + }; + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; const mockProjectId = "550e8400-e29b-41d4-a716-446655440003"; @@ -59,12 +65,17 @@ describe("ProjectsService", () => { provide: ActivityService, useValue: mockActivityService, }, + { + provide: WebSocketGateway, + useValue: mockWebSocketGateway, + }, ], }).compile(); service = module.get(ProjectsService); prisma = module.get(PrismaService); activityService = module.get(ActivityService); + wsGateway = module.get(WebSocketGateway); vi.clearAllMocks(); }); diff --git a/apps/api/src/websocket/websocket.gateway.spec.ts b/apps/api/src/websocket/websocket.gateway.spec.ts new file mode 100644 index 0000000..a096614 --- /dev/null +++ b/apps/api/src/websocket/websocket.gateway.spec.ts @@ -0,0 +1,175 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WebSocketGateway } from './websocket.gateway'; +import { Server, Socket } from 'socket.io'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface AuthenticatedSocket extends Socket { + data: { + userId: string; + workspaceId: string; + }; +} + +describe('WebSocketGateway', () => { + let gateway: WebSocketGateway; + let mockServer: Server; + let mockClient: AuthenticatedSocket; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [WebSocketGateway], + }).compile(); + + gateway = module.get(WebSocketGateway); + + // Mock Socket.IO server + mockServer = { + to: vi.fn().mockReturnThis(), + emit: vi.fn(), + } as unknown as Server; + + // Mock authenticated client + mockClient = { + id: 'test-socket-id', + join: vi.fn(), + leave: vi.fn(), + emit: vi.fn(), + data: { + userId: 'user-123', + workspaceId: 'workspace-456', + }, + handshake: { + auth: { + token: 'valid-token', + }, + }, + } as unknown as AuthenticatedSocket; + + gateway.server = mockServer; + }); + + describe('handleConnection', () => { + it('should join client to workspace room on connection', async () => { + await gateway.handleConnection(mockClient); + + expect(mockClient.join).toHaveBeenCalledWith('workspace:workspace-456'); + }); + + it('should reject connection without authentication', async () => { + const unauthClient = { + ...mockClient, + data: {}, + disconnect: vi.fn(), + } as unknown as AuthenticatedSocket; + + await gateway.handleConnection(unauthClient); + + expect(unauthClient.disconnect).toHaveBeenCalled(); + }); + }); + + describe('handleDisconnect', () => { + it('should leave workspace room on disconnect', () => { + gateway.handleDisconnect(mockClient); + + expect(mockClient.leave).toHaveBeenCalledWith('workspace:workspace-456'); + }); + }); + + describe('emitTaskCreated', () => { + it('should emit task:created event to workspace room', () => { + const task = { + id: 'task-1', + title: 'Test Task', + workspaceId: 'workspace-456', + }; + + gateway.emitTaskCreated('workspace-456', task); + + expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456'); + expect(mockServer.emit).toHaveBeenCalledWith('task:created', task); + }); + }); + + describe('emitTaskUpdated', () => { + it('should emit task:updated event to workspace room', () => { + const task = { + id: 'task-1', + title: 'Updated Task', + workspaceId: 'workspace-456', + }; + + gateway.emitTaskUpdated('workspace-456', task); + + expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456'); + expect(mockServer.emit).toHaveBeenCalledWith('task:updated', task); + }); + }); + + describe('emitTaskDeleted', () => { + it('should emit task:deleted event to workspace room', () => { + const taskId = 'task-1'; + + gateway.emitTaskDeleted('workspace-456', taskId); + + expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456'); + expect(mockServer.emit).toHaveBeenCalledWith('task:deleted', { id: taskId }); + }); + }); + + describe('emitEventCreated', () => { + it('should emit event:created event to workspace room', () => { + const event = { + id: 'event-1', + title: 'Test Event', + workspaceId: 'workspace-456', + }; + + gateway.emitEventCreated('workspace-456', event); + + expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456'); + expect(mockServer.emit).toHaveBeenCalledWith('event:created', event); + }); + }); + + describe('emitEventUpdated', () => { + it('should emit event:updated event to workspace room', () => { + const event = { + id: 'event-1', + title: 'Updated Event', + workspaceId: 'workspace-456', + }; + + gateway.emitEventUpdated('workspace-456', event); + + expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456'); + expect(mockServer.emit).toHaveBeenCalledWith('event:updated', event); + }); + }); + + describe('emitEventDeleted', () => { + it('should emit event:deleted event to workspace room', () => { + const eventId = 'event-1'; + + gateway.emitEventDeleted('workspace-456', eventId); + + expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456'); + expect(mockServer.emit).toHaveBeenCalledWith('event:deleted', { id: eventId }); + }); + }); + + describe('emitProjectUpdated', () => { + it('should emit project:updated event to workspace room', () => { + const project = { + id: 'project-1', + name: 'Updated Project', + workspaceId: 'workspace-456', + }; + + gateway.emitProjectUpdated('workspace-456', project); + + expect(mockServer.to).toHaveBeenCalledWith('workspace:workspace-456'); + expect(mockServer.emit).toHaveBeenCalledWith('project:updated', project); + }); + }); +}); diff --git a/apps/api/src/websocket/websocket.gateway.ts b/apps/api/src/websocket/websocket.gateway.ts new file mode 100644 index 0000000..1ad17f6 --- /dev/null +++ b/apps/api/src/websocket/websocket.gateway.ts @@ -0,0 +1,153 @@ +import { + WebSocketGateway as WSGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; + +interface AuthenticatedSocket extends Socket { + data: { + userId?: string; + workspaceId?: string; + }; +} + +interface Task { + id: string; + workspaceId: string; + [key: string]: unknown; +} + +interface Event { + id: string; + workspaceId: string; + [key: string]: unknown; +} + +interface Project { + id: string; + workspaceId: string; + [key: string]: unknown; +} + +/** + * WebSocket Gateway for real-time updates + * Handles workspace-scoped rooms for broadcasting events + */ +@WSGateway({ + cors: { + origin: process.env.WEB_URL || 'http://localhost:3000', + credentials: true, + }, +}) +export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server!: Server; + + private readonly logger = new Logger(WebSocketGateway.name); + + /** + * Handle client connection + * Joins client to workspace-specific room + */ + async handleConnection(client: AuthenticatedSocket): Promise { + const { userId, workspaceId } = client.data; + + if (!userId || !workspaceId) { + this.logger.warn(`Client ${client.id} connected without authentication`); + client.disconnect(); + return; + } + + const room = this.getWorkspaceRoom(workspaceId); + await client.join(room); + + this.logger.log(`Client ${client.id} joined room ${room}`); + } + + /** + * Handle client disconnect + * Leaves workspace room + */ + handleDisconnect(client: AuthenticatedSocket): void { + const { workspaceId } = client.data; + + if (workspaceId) { + const room = this.getWorkspaceRoom(workspaceId); + client.leave(room); + this.logger.log(`Client ${client.id} left room ${room}`); + } + } + + /** + * Emit task:created event to workspace room + */ + emitTaskCreated(workspaceId: string, task: Task): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('task:created', task); + this.logger.debug(`Emitted task:created to ${room}`); + } + + /** + * Emit task:updated event to workspace room + */ + emitTaskUpdated(workspaceId: string, task: Task): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('task:updated', task); + this.logger.debug(`Emitted task:updated to ${room}`); + } + + /** + * Emit task:deleted event to workspace room + */ + emitTaskDeleted(workspaceId: string, taskId: string): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('task:deleted', { id: taskId }); + this.logger.debug(`Emitted task:deleted to ${room}`); + } + + /** + * Emit event:created event to workspace room + */ + emitEventCreated(workspaceId: string, event: Event): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('event:created', event); + this.logger.debug(`Emitted event:created to ${room}`); + } + + /** + * Emit event:updated event to workspace room + */ + emitEventUpdated(workspaceId: string, event: Event): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('event:updated', event); + this.logger.debug(`Emitted event:updated to ${room}`); + } + + /** + * Emit event:deleted event to workspace room + */ + emitEventDeleted(workspaceId: string, eventId: string): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('event:deleted', { id: eventId }); + this.logger.debug(`Emitted event:deleted to ${room}`); + } + + /** + * Emit project:updated event to workspace room + */ + emitProjectUpdated(workspaceId: string, project: Project): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('project:updated', project); + this.logger.debug(`Emitted project:updated to ${room}`); + } + + /** + * Get workspace room name + */ + private getWorkspaceRoom(workspaceId: string): string { + return `workspace:${workspaceId}`; + } +} diff --git a/apps/api/src/websocket/websocket.module.ts b/apps/api/src/websocket/websocket.module.ts new file mode 100644 index 0000000..3bca8d7 --- /dev/null +++ b/apps/api/src/websocket/websocket.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { WebSocketGateway } from './websocket.gateway'; + +/** + * WebSocket module for real-time updates + */ +@Module({ + providers: [WebSocketGateway], + exports: [WebSocketGateway], +}) +export class WebSocketModule {} diff --git a/apps/web/src/app/demo/gantt/page.tsx b/apps/web/src/app/demo/gantt/page.tsx new file mode 100644 index 0000000..73638dd --- /dev/null +++ b/apps/web/src/app/demo/gantt/page.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useState } from "react"; +import { GanttChart, toGanttTasks } from "@/components/gantt"; +import type { GanttTask } from "@/components/gantt"; +import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared"; + +/** + * Demo page for Gantt Chart component + * + * This page demonstrates the GanttChart component with sample data + * showing various task states, durations, and interactions. + */ +export default function GanttDemoPage(): JSX.Element { + // Sample tasks for demonstration + const baseTasks: Task[] = [ + { + id: "task-1", + workspaceId: "demo-workspace", + title: "Project Planning", + description: "Initial project planning and requirements gathering", + status: TaskStatus.COMPLETED, + priority: TaskPriority.HIGH, + dueDate: new Date("2026-02-10"), + assigneeId: null, + creatorId: "demo-user", + projectId: null, + parentId: null, + sortOrder: 0, + metadata: { + startDate: "2026-02-01", + }, + completedAt: new Date("2026-02-09"), + createdAt: new Date("2026-02-01"), + updatedAt: new Date("2026-02-09"), + }, + { + id: "task-2", + workspaceId: "demo-workspace", + title: "Design Phase", + description: "Create mockups and design system", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + dueDate: new Date("2026-02-25"), + assigneeId: null, + creatorId: "demo-user", + projectId: null, + parentId: null, + sortOrder: 1, + metadata: { + startDate: "2026-02-11", + dependencies: ["task-1"], + }, + completedAt: null, + createdAt: new Date("2026-02-11"), + updatedAt: new Date("2026-02-15"), + }, + { + id: "task-3", + workspaceId: "demo-workspace", + title: "Backend Development", + description: "Build API and database", + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + dueDate: new Date("2026-03-20"), + assigneeId: null, + creatorId: "demo-user", + projectId: null, + parentId: null, + sortOrder: 2, + metadata: { + startDate: "2026-02-20", + dependencies: ["task-1"], + }, + completedAt: null, + createdAt: new Date("2026-02-01"), + updatedAt: new Date("2026-02-01"), + }, + { + id: "task-4", + workspaceId: "demo-workspace", + title: "Frontend Development", + description: "Build user interface components", + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + dueDate: new Date("2026-03-25"), + assigneeId: null, + creatorId: "demo-user", + projectId: null, + parentId: null, + sortOrder: 3, + metadata: { + startDate: "2026-02-26", + dependencies: ["task-2"], + }, + completedAt: null, + createdAt: new Date("2026-02-01"), + updatedAt: new Date("2026-02-01"), + }, + { + id: "task-5", + workspaceId: "demo-workspace", + title: "Integration Testing", + description: "Test all components together", + status: TaskStatus.PAUSED, + priority: TaskPriority.HIGH, + dueDate: new Date("2026-04-05"), + assigneeId: null, + creatorId: "demo-user", + projectId: null, + parentId: null, + sortOrder: 4, + metadata: { + startDate: "2026-03-26", + dependencies: ["task-3", "task-4"], + }, + completedAt: null, + createdAt: new Date("2026-02-01"), + updatedAt: new Date("2026-03-15"), + }, + { + id: "task-6", + workspaceId: "demo-workspace", + title: "Deployment", + description: "Deploy to production", + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.HIGH, + dueDate: new Date("2026-04-10"), + assigneeId: null, + creatorId: "demo-user", + projectId: null, + parentId: null, + sortOrder: 5, + metadata: { + startDate: "2026-04-06", + dependencies: ["task-5"], + }, + completedAt: null, + createdAt: new Date("2026-02-01"), + updatedAt: new Date("2026-02-01"), + }, + { + id: "task-7", + workspaceId: "demo-workspace", + title: "Documentation", + description: "Write user and developer documentation", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.LOW, + dueDate: new Date("2026-04-08"), + assigneeId: null, + creatorId: "demo-user", + projectId: null, + parentId: null, + sortOrder: 6, + metadata: { + startDate: "2026-03-01", + }, + completedAt: null, + createdAt: new Date("2026-03-01"), + updatedAt: new Date("2026-03-10"), + }, + ]; + + const ganttTasks = toGanttTasks(baseTasks); + const [selectedTask, setSelectedTask] = useState(null); + const [showDependencies, setShowDependencies] = useState(false); + + const handleTaskClick = (task: GanttTask): void => { + setSelectedTask(task); + }; + + const statusCounts = { + total: ganttTasks.length, + completed: ganttTasks.filter((t) => t.status === TaskStatus.COMPLETED).length, + inProgress: ganttTasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length, + notStarted: ganttTasks.filter((t) => t.status === TaskStatus.NOT_STARTED).length, + paused: ganttTasks.filter((t) => t.status === TaskStatus.PAUSED).length, + }; + + return ( +
+
+ {/* Header */} +
+

+ Gantt Chart Component Demo +

+

+ Interactive project timeline visualization with task dependencies +

+
+ + {/* Stats */} +
+
+
{statusCounts.total}
+
Total Tasks
+
+
+
{statusCounts.completed}
+
Completed
+
+
+
{statusCounts.inProgress}
+
In Progress
+
+
+
{statusCounts.notStarted}
+
Not Started
+
+
+
{statusCounts.paused}
+
Paused
+
+
+ + {/* Controls */} +
+
+ +
+
+ + {/* Gantt Chart */} +
+
+

+ Project Timeline +

+
+
+ +
+
+ + {/* Selected Task Details */} + {selectedTask && ( +
+

+ Selected Task Details +

+
+
+
Title
+
{selectedTask.title}
+
+
+
Status
+
{selectedTask.status}
+
+
+
Priority
+
{selectedTask.priority}
+
+
+
Duration
+
+ {Math.ceil( + (selectedTask.endDate.getTime() - selectedTask.startDate.getTime()) / + (1000 * 60 * 60 * 24) + )}{" "} + days +
+
+
+
Start Date
+
+ {selectedTask.startDate.toLocaleDateString()} +
+
+
+
End Date
+
+ {selectedTask.endDate.toLocaleDateString()} +
+
+ {selectedTask.description && ( +
+
Description
+
{selectedTask.description}
+
+ )} +
+
+ )} + + {/* PDA-Friendly Language Notice */} +
+

+ 🌟 PDA-Friendly Design +

+

+ This component uses respectful, non-judgmental language. Tasks past their target + date show "Target passed" instead of "OVERDUE", and approaching deadlines show + "Approaching target" to maintain a positive, supportive tone. +

+
+
+
+ ); +} diff --git a/apps/web/src/components/gantt/GanttChart.test.tsx b/apps/web/src/components/gantt/GanttChart.test.tsx new file mode 100644 index 0000000..27f2877 --- /dev/null +++ b/apps/web/src/components/gantt/GanttChart.test.tsx @@ -0,0 +1,401 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { GanttChart } from "./GanttChart"; +import { TaskStatus, TaskPriority } from "@mosaic/shared"; +import type { GanttTask } from "./types"; + +describe("GanttChart", () => { + const baseDate = new Date("2026-02-01T00:00:00Z"); + + const createGanttTask = (overrides: Partial = {}): GanttTask => ({ + id: `task-${Math.random()}`, + workspaceId: "workspace-1", + title: "Sample Task", + description: null, + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + dueDate: new Date("2026-02-15T00:00:00Z"), + assigneeId: null, + creatorId: "user-1", + projectId: null, + parentId: null, + sortOrder: 0, + metadata: {}, + completedAt: null, + createdAt: baseDate, + updatedAt: baseDate, + startDate: new Date("2026-02-01T00:00:00Z"), + endDate: new Date("2026-02-15T00:00:00Z"), + ...overrides, + }); + + describe("Rendering", () => { + it("should render without crashing with empty task list", () => { + render(); + expect(screen.getByRole("region", { name: /gantt chart/i })).toBeInTheDocument(); + }); + + it("should render task names in the task list", () => { + const tasks = [ + createGanttTask({ id: "task-1", title: "Design mockups" }), + createGanttTask({ id: "task-2", title: "Implement frontend" }), + ]; + + render(); + + // Tasks appear in both the list and bars, so use getAllByText + expect(screen.getAllByText("Design mockups").length).toBeGreaterThan(0); + expect(screen.getAllByText("Implement frontend").length).toBeGreaterThan(0); + }); + + it("should render timeline bars for each task", () => { + const tasks = [ + createGanttTask({ id: "task-1", title: "Task 1" }), + createGanttTask({ id: "task-2", title: "Task 2" }), + ]; + + render(); + + const bars = screen.getAllByRole("button", { name: /gantt bar/i }); + expect(bars).toHaveLength(2); + }); + + it("should display date headers for the timeline", () => { + const tasks = [ + createGanttTask({ + startDate: new Date("2026-02-01"), + endDate: new Date("2026-02-10"), + }), + ]; + + render(); + + // Should show month or date indicators + const timeline = screen.getByRole("region", { name: /timeline/i }); + expect(timeline).toBeInTheDocument(); + }); + }); + + describe("Task Status Indicators", () => { + it("should visually distinguish completed tasks", () => { + const tasks = [ + createGanttTask({ + id: "completed-task", + title: "Completed Task", + status: TaskStatus.COMPLETED, + completedAt: new Date("2026-02-10"), + }), + ]; + + render(); + + const taskRow = screen.getAllByText("Completed Task")[0].closest("[role='row']"); + expect(taskRow).toHaveClass(/completed/i); + }); + + it("should visually distinguish in-progress tasks", () => { + const tasks = [ + createGanttTask({ + id: "active-task", + title: "Active Task", + status: TaskStatus.IN_PROGRESS, + }), + ]; + + render(); + + const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']"); + expect(taskRow).toHaveClass(/in-progress/i); + }); + }); + + describe("PDA-friendly language", () => { + it('should show "Target passed" for tasks past their end date', () => { + const pastTask = createGanttTask({ + id: "past-task", + title: "Past Task", + startDate: new Date("2020-01-01"), + endDate: new Date("2020-01-15"), + status: TaskStatus.NOT_STARTED, + }); + + render(); + + // Should show "Target passed" not "OVERDUE" + expect(screen.getByText(/target passed/i)).toBeInTheDocument(); + expect(screen.queryByText(/overdue/i)).not.toBeInTheDocument(); + }); + + it('should show "Approaching target" for tasks near their end date', () => { + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const soonTask = createGanttTask({ + id: "soon-task", + title: "Soon Task", + startDate: today, + endDate: tomorrow, + status: TaskStatus.IN_PROGRESS, + }); + + render(); + + expect(screen.getByText(/approaching target/i)).toBeInTheDocument(); + }); + }); + + describe("Task Interactions", () => { + it("should call onTaskClick when a task bar is clicked", async () => { + const user = userEvent.setup(); + const onTaskClick = vi.fn(); + + const task = createGanttTask({ id: "clickable-task", title: "Click Me" }); + + render(); + + const taskBar = screen.getByRole("button", { name: /gantt bar.*click me/i }); + await user.click(taskBar); + + expect(onTaskClick).toHaveBeenCalledWith(task); + }); + + it("should not crash when clicking a task without onTaskClick handler", async () => { + const user = userEvent.setup(); + const task = createGanttTask({ id: "task-1", title: "No Handler" }); + + render(); + + const taskBar = screen.getByRole("button", { name: /gantt bar/i }); + await user.click(taskBar); + + // Should not throw + expect(taskBar).toBeInTheDocument(); + }); + }); + + describe("Timeline Calculations", () => { + it("should calculate timeline range from task dates", () => { + const tasks = [ + createGanttTask({ + id: "early-task", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-01-10"), + }), + createGanttTask({ + id: "late-task", + startDate: new Date("2026-03-01"), + endDate: new Date("2026-03-31"), + }), + ]; + + render(); + + // Timeline should span from earliest start to latest end + const timeline = screen.getByRole("region", { name: /timeline/i }); + expect(timeline).toBeInTheDocument(); + }); + + it("should position task bars proportionally to their dates", () => { + const tasks = [ + createGanttTask({ + id: "task-1", + title: "Task 1", + startDate: new Date("2026-02-01"), + endDate: new Date("2026-02-05"), // 4 days + }), + createGanttTask({ + id: "task-2", + title: "Task 2", + startDate: new Date("2026-02-01"), + endDate: new Date("2026-02-11"), // 10 days + }), + ]; + + render(); + + const bars = screen.getAllByRole("button", { name: /gantt bar/i }); + expect(bars).toHaveLength(2); + + // Second bar should be wider (more days) + const bar1Width = bars[0].style.width; + const bar2Width = bars[1].style.width; + + // Basic check that widths are set (exact values depend on implementation) + expect(bar1Width).toBeTruthy(); + expect(bar2Width).toBeTruthy(); + }); + }); + + describe("Accessibility", () => { + it("should have proper ARIA labels for the chart region", () => { + render(); + + expect(screen.getByRole("region", { name: /gantt chart/i })).toBeInTheDocument(); + }); + + it("should have proper ARIA labels for task bars", () => { + const task = createGanttTask({ + id: "task-1", + title: "Accessible Task", + startDate: new Date("2026-02-01"), + endDate: new Date("2026-02-15"), + }); + + render(); + + const taskBar = screen.getByRole("button", { + name: /gantt bar.*accessible task/i, + }); + expect(taskBar).toHaveAccessibleName(); + }); + + it("should be keyboard navigable", async () => { + const user = userEvent.setup(); + const onTaskClick = vi.fn(); + + const task = createGanttTask({ id: "task-1", title: "Keyboard Task" }); + + render(); + + const taskBar = screen.getByRole("button", { name: /gantt bar/i }); + + // Tab to focus + await user.tab(); + expect(taskBar).toHaveFocus(); + + // Enter to activate + await user.keyboard("{Enter}"); + expect(onTaskClick).toHaveBeenCalled(); + }); + }); + + describe("Responsive Design", () => { + it("should accept custom height prop", () => { + const tasks = [createGanttTask({ id: "task-1" })]; + + render(); + + const chart = screen.getByRole("region", { name: /gantt chart/i }); + expect(chart).toHaveStyle({ height: "600px" }); + }); + + it("should use default height when not specified", () => { + const tasks = [createGanttTask({ id: "task-1" })]; + + render(); + + const chart = screen.getByRole("region", { name: /gantt chart/i }); + expect(chart).toBeInTheDocument(); + // Default height should be set in implementation + }); + }); + + describe("Edge Cases", () => { + it("should handle tasks with same start and end date", () => { + const sameDay = new Date("2026-02-01"); + const task = createGanttTask({ + id: "same-day", + title: "Same Day Task", + startDate: sameDay, + endDate: sameDay, + }); + + render(); + + expect(screen.getAllByText("Same Day Task").length).toBeGreaterThan(0); + const bar = screen.getByRole("button", { name: /gantt bar/i }); + expect(bar).toBeInTheDocument(); + // Bar should have minimum width + }); + + it("should handle tasks with very long duration", () => { + const task = createGanttTask({ + id: "long-task", + title: "Long Task", + startDate: new Date("2026-01-01"), + endDate: new Date("2027-12-31"), // 2 years + }); + + render(); + + expect(screen.getAllByText("Long Task").length).toBeGreaterThan(0); + }); + + it("should sort tasks by start date", () => { + const tasks = [ + createGanttTask({ + id: "late-task", + title: "Late Task", + startDate: new Date("2026-03-01"), + }), + createGanttTask({ + id: "early-task", + title: "Early Task", + startDate: new Date("2026-01-01"), + }), + createGanttTask({ + id: "mid-task", + title: "Mid Task", + startDate: new Date("2026-02-01"), + }), + ]; + + render(); + + const taskNames = screen.getAllByRole("row").map((row) => row.textContent); + + // Early Task should appear first + const earlyIndex = taskNames.findIndex((name) => name?.includes("Early Task")); + const lateIndex = taskNames.findIndex((name) => name?.includes("Late Task")); + + expect(earlyIndex).toBeLessThan(lateIndex); + }); + }); + + describe("Dependencies (stretch goal)", () => { + it("should render dependency lines when showDependencies is true", () => { + const tasks = [ + createGanttTask({ + id: "task-1", + title: "Foundation", + }), + createGanttTask({ + id: "task-2", + title: "Build on top", + dependencies: ["task-1"], + }), + ]; + + render(); + + // Check if dependency visualization exists + const chart = screen.getByRole("region", { name: /gantt chart/i }); + expect(chart).toBeInTheDocument(); + + // Specific dependency rendering will depend on implementation + // This is a basic check that the prop is accepted + }); + + it("should not render dependencies by default", () => { + const tasks = [ + createGanttTask({ + id: "task-1", + title: "Task 1", + }), + createGanttTask({ + id: "task-2", + title: "Task 2", + dependencies: ["task-1"], + }), + ]; + + render(); + + // Dependencies should not be shown by default + const chart = screen.getByRole("region", { name: /gantt chart/i }); + expect(chart).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/gantt/GanttChart.tsx b/apps/web/src/components/gantt/GanttChart.tsx new file mode 100644 index 0000000..8190a2a --- /dev/null +++ b/apps/web/src/components/gantt/GanttChart.tsx @@ -0,0 +1,299 @@ +"use client"; + +import type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types"; +import { TaskStatus } from "@mosaic/shared"; +import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format"; +import { useMemo } from "react"; + +/** + * Calculate the timeline range from a list of tasks + */ +function calculateTimelineRange(tasks: GanttTask[]): TimelineRange { + if (tasks.length === 0) { + const now = new Date(); + const oneMonthLater = new Date(now); + oneMonthLater.setMonth(oneMonthLater.getMonth() + 1); + + return { + start: now, + end: oneMonthLater, + totalDays: 30, + }; + } + + let earliest = tasks[0].startDate; + let latest = tasks[0].endDate; + + tasks.forEach((task) => { + if (task.startDate < earliest) { + earliest = task.startDate; + } + if (task.endDate > latest) { + latest = task.endDate; + } + }); + + // Add padding (5% on each side) + const totalMs = latest.getTime() - earliest.getTime(); + const padding = totalMs * 0.05; + + const start = new Date(earliest.getTime() - padding); + const end = new Date(latest.getTime() + padding); + + const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); + + return { start, end, totalDays }; +} + +/** + * Calculate the position and width for a task bar + */ +function calculateBarPosition( + task: GanttTask, + timelineRange: TimelineRange, + rowIndex: number +): GanttBarPosition { + const { start: rangeStart, totalDays } = timelineRange; + + const taskStartOffset = Math.max( + 0, + (task.startDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24) + ); + + const taskDuration = Math.max( + 0.5, // Minimum 0.5 day width for visibility + (task.endDate.getTime() - task.startDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + const leftPercent = (taskStartOffset / totalDays) * 100; + const widthPercent = (taskDuration / totalDays) * 100; + + return { + left: `${leftPercent}%`, + width: `${widthPercent}%`, + top: rowIndex * 48, // 48px row height + }; +} + +/** + * Get CSS class for task status + */ +function getStatusClass(status: TaskStatus): string { + switch (status) { + case TaskStatus.COMPLETED: + return "bg-green-500"; + case TaskStatus.IN_PROGRESS: + return "bg-blue-500"; + case TaskStatus.PAUSED: + return "bg-yellow-500"; + case TaskStatus.ARCHIVED: + return "bg-gray-400"; + default: + return "bg-gray-500"; + } +} + +/** + * Get CSS class for task row status + */ +function getRowStatusClass(status: TaskStatus): string { + switch (status) { + case TaskStatus.COMPLETED: + return "gantt-row-completed"; + case TaskStatus.IN_PROGRESS: + return "gantt-row-in-progress"; + case TaskStatus.PAUSED: + return "gantt-row-paused"; + default: + return ""; + } +} + +/** + * Generate month labels for the timeline header + */ +function generateTimelineLabels(range: TimelineRange): Array<{ label: string; position: number }> { + const labels: Array<{ label: string; position: number }> = []; + const current = new Date(range.start); + + // Generate labels for each month in the range + while (current <= range.end) { + const position = + ((current.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)) / range.totalDays; + + const label = current.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }); + + labels.push({ label, position: position * 100 }); + + // Move to next month + current.setMonth(current.getMonth() + 1); + } + + return labels; +} + +/** + * Main Gantt Chart Component + */ +export function GanttChart({ + tasks, + onTaskClick, + height = 400, + showDependencies = false, +}: GanttChartProps): JSX.Element { + // Sort tasks by start date + const sortedTasks = useMemo(() => { + return [...tasks].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); + }, [tasks]); + + // Calculate timeline range + const timelineRange = useMemo(() => calculateTimelineRange(sortedTasks), [sortedTasks]); + + // Generate timeline labels + const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]); + + const handleTaskClick = (task: GanttTask) => (): void => { + if (onTaskClick) { + onTaskClick(task); + } + }; + + const handleKeyDown = (task: GanttTask) => (e: React.KeyboardEvent): void => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (onTaskClick) { + onTaskClick(task); + } + } + }; + + return ( +
+
+ {/* Task list column */} +
+
+ Tasks +
+
+ {sortedTasks.map((task, index) => { + const isPast = isPastTarget(task.endDate); + const isApproaching = !isPast && isApproachingTarget(task.endDate); + + return ( +
+
+
+ {task.title} +
+ {isPast && task.status !== TaskStatus.COMPLETED && ( +
Target passed
+ )} + {isApproaching && task.status !== TaskStatus.COMPLETED && ( +
Approaching target
+ )} +
+
+ ); + })} +
+
+ + {/* Timeline column */} +
+
+ {/* Timeline header */} +
+ {timelineLabels.map((label, index) => ( +
+ {label.label} +
+ ))} +
+ + {/* Timeline grid and bars */} +
+ {/* Grid lines */} +
+ {timelineLabels.map((label, index) => ( +
+ ))} +
+ + {/* Task bars */} + {sortedTasks.map((task, index) => { + const position = calculateBarPosition(task, timelineRange, index); + const statusClass = getStatusClass(task.status); + + return ( +
+
+ {task.title} +
+
+ ); + })} + + {/* Spacer for scrolling */} +
+
+
+
+
+ + {/* CSS for status classes */} + +
+ ); +} diff --git a/apps/web/src/components/gantt/index.ts b/apps/web/src/components/gantt/index.ts new file mode 100644 index 0000000..0775b57 --- /dev/null +++ b/apps/web/src/components/gantt/index.ts @@ -0,0 +1,7 @@ +/** + * Gantt Chart component exports + */ + +export { GanttChart } from "./GanttChart"; +export type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types"; +export { toGanttTask, toGanttTasks } from "./types"; diff --git a/apps/web/src/components/gantt/types.test.ts b/apps/web/src/components/gantt/types.test.ts new file mode 100644 index 0000000..9aff77e --- /dev/null +++ b/apps/web/src/components/gantt/types.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from "vitest"; +import { toGanttTask, toGanttTasks } from "./types"; +import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared"; + +describe("Gantt Types Helpers", () => { + const baseDate = new Date("2026-02-01T00:00:00Z"); + + const createTask = (overrides: Partial = {}): Task => ({ + id: "task-1", + workspaceId: "workspace-1", + title: "Sample Task", + description: null, + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + dueDate: new Date("2026-02-15T00:00:00Z"), + assigneeId: null, + creatorId: "user-1", + projectId: null, + parentId: null, + sortOrder: 0, + metadata: {}, + completedAt: null, + createdAt: baseDate, + updatedAt: baseDate, + ...overrides, + }); + + describe("toGanttTask", () => { + it("should convert a Task with metadata.startDate to GanttTask", () => { + const task = createTask({ + metadata: { + startDate: "2026-02-05", + }, + dueDate: new Date("2026-02-15"), + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.startDate).toBeInstanceOf(Date); + expect(ganttTask?.endDate).toBeInstanceOf(Date); + expect(ganttTask?.startDate.getTime()).toBe(new Date("2026-02-05").getTime()); + expect(ganttTask?.endDate.getTime()).toBe(new Date("2026-02-15").getTime()); + }); + + it("should use createdAt as startDate if metadata.startDate is not provided", () => { + const task = createTask({ + createdAt: new Date("2026-02-01"), + dueDate: new Date("2026-02-15"), + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.startDate.getTime()).toBe(new Date("2026-02-01").getTime()); + }); + + it("should use current date as endDate if dueDate is null", () => { + const beforeConversion = Date.now(); + const task = createTask({ + dueDate: null, + metadata: { + startDate: "2026-02-01", + }, + }); + + const ganttTask = toGanttTask(task); + const afterConversion = Date.now(); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.endDate.getTime()).toBeGreaterThanOrEqual(beforeConversion); + expect(ganttTask?.endDate.getTime()).toBeLessThanOrEqual(afterConversion); + }); + + it("should extract dependencies from metadata", () => { + const task = createTask({ + metadata: { + startDate: "2026-02-01", + dependencies: ["task-a", "task-b"], + }, + dueDate: new Date("2026-02-15"), + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.dependencies).toEqual(["task-a", "task-b"]); + }); + + it("should handle missing dependencies in metadata", () => { + const task = createTask({ + metadata: { + startDate: "2026-02-01", + }, + dueDate: new Date("2026-02-15"), + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.dependencies).toBeUndefined(); + }); + + it("should handle non-array dependencies in metadata", () => { + const task = createTask({ + metadata: { + startDate: "2026-02-01", + dependencies: "not-an-array", + }, + dueDate: new Date("2026-02-15"), + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.dependencies).toBeUndefined(); + }); + + it("should preserve all original task properties", () => { + const task = createTask({ + id: "special-task", + title: "Special Task", + description: "This is special", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + metadata: { + startDate: "2026-02-01", + }, + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.id).toBe("special-task"); + expect(ganttTask?.title).toBe("Special Task"); + expect(ganttTask?.description).toBe("This is special"); + expect(ganttTask?.status).toBe(TaskStatus.IN_PROGRESS); + expect(ganttTask?.priority).toBe(TaskPriority.HIGH); + }); + }); + + describe("toGanttTasks", () => { + it("should convert multiple tasks to GanttTasks", () => { + const tasks = [ + createTask({ + id: "task-1", + metadata: { startDate: "2026-02-01" }, + dueDate: new Date("2026-02-10"), + }), + createTask({ + id: "task-2", + metadata: { startDate: "2026-02-11" }, + dueDate: new Date("2026-02-20"), + }), + ]; + + const ganttTasks = toGanttTasks(tasks); + + expect(ganttTasks).toHaveLength(2); + expect(ganttTasks[0].id).toBe("task-1"); + expect(ganttTasks[1].id).toBe("task-2"); + }); + + it("should filter out tasks that cannot be converted", () => { + const tasks = [ + createTask({ + id: "task-1", + metadata: { startDate: "2026-02-01" }, + dueDate: new Date("2026-02-10"), + }), + createTask({ + id: "task-2", + metadata: { startDate: "2026-02-11" }, + dueDate: new Date("2026-02-20"), + }), + ]; + + const ganttTasks = toGanttTasks(tasks); + + // All valid tasks should be converted + expect(ganttTasks).toHaveLength(2); + }); + + it("should handle empty array", () => { + const ganttTasks = toGanttTasks([]); + + expect(ganttTasks).toEqual([]); + }); + + it("should maintain order of tasks", () => { + const tasks = [ + createTask({ id: "first", metadata: { startDate: "2026-03-01" } }), + createTask({ id: "second", metadata: { startDate: "2026-02-01" } }), + createTask({ id: "third", metadata: { startDate: "2026-01-01" } }), + ]; + + const ganttTasks = toGanttTasks(tasks); + + expect(ganttTasks[0].id).toBe("first"); + expect(ganttTasks[1].id).toBe("second"); + expect(ganttTasks[2].id).toBe("third"); + }); + }); +}); diff --git a/apps/web/src/components/gantt/types.ts b/apps/web/src/components/gantt/types.ts new file mode 100644 index 0000000..06aa381 --- /dev/null +++ b/apps/web/src/components/gantt/types.ts @@ -0,0 +1,95 @@ +/** + * Gantt chart types + * Extends base Task type with start/end dates for timeline visualization + */ + +import type { Task, TaskStatus, TaskPriority } from "@mosaic/shared"; + +/** + * Extended task type for Gantt chart display + * Adds explicit start and end dates required for timeline visualization + */ +export interface GanttTask extends Task { + /** Start date for the task (required for Gantt visualization) */ + startDate: Date; + /** End date for the task (maps to dueDate but explicit for clarity) */ + endDate: Date; + /** Optional array of task IDs that this task depends on */ + dependencies?: string[]; +} + +/** + * Position and dimensions for a Gantt bar in the timeline + */ +export interface GanttBarPosition { + /** Left offset from timeline start (in pixels or percentage) */ + left: string; + /** Width of the bar (in pixels or percentage) */ + width: string; + /** Top offset for vertical positioning */ + top: number; +} + +/** + * Date range for the entire Gantt chart timeline + */ +export interface TimelineRange { + /** Earliest date to display */ + start: Date; + /** Latest date to display */ + end: Date; + /** Total number of days in the range */ + totalDays: number; +} + +/** + * Props for the main GanttChart component + */ +export interface GanttChartProps { + /** Tasks to display in the Gantt chart */ + tasks: GanttTask[]; + /** Optional callback when a task bar is clicked */ + onTaskClick?: (task: GanttTask) => void; + /** Optional height for the chart container */ + height?: number; + /** Whether to show task dependencies (default: false) */ + showDependencies?: boolean; +} + +/** + * Helper to convert a base Task to GanttTask + * Uses createdAt as startDate if not in metadata, dueDate as endDate + */ +export function toGanttTask(task: Task): GanttTask | null { + // For Gantt chart, we need both start and end dates + const startDate = + (task.metadata?.startDate as string | undefined) + ? new Date(task.metadata.startDate as string) + : task.createdAt; + + const endDate = task.dueDate || new Date(); + + // Validate dates + if (!startDate || !endDate) { + return null; + } + + return { + ...task, + startDate, + endDate, + dependencies: Array.isArray(task.metadata?.dependencies) + ? (task.metadata.dependencies as string[]) + : undefined, + }; +} + +/** + * Helper to get all GanttTasks from an array of Tasks + * Filters out tasks that don't have valid date ranges + */ +export function toGanttTasks(tasks: Task[]): GanttTask[] { + return tasks + .map(toGanttTask) + .filter((task): task is GanttTask => task !== null); +} diff --git a/apps/web/src/lib/api/domains.ts b/apps/web/src/lib/api/domains.ts new file mode 100644 index 0000000..2084f98 --- /dev/null +++ b/apps/web/src/lib/api/domains.ts @@ -0,0 +1,97 @@ +/** + * Domain API Client + * Handles domain-related API requests + */ + +import type { Domain, DomainWithCounts } from "@mosaic/shared"; +import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client"; + +/** + * Create domain DTO + */ +export interface CreateDomainDto { + name: string; + slug: string; + description?: string; + color?: string; + icon?: string; + sortOrder?: number; + metadata?: Record; +} + +/** + * Update domain DTO + */ +export interface UpdateDomainDto { + name?: string; + slug?: string; + description?: string; + color?: string; + icon?: string; + sortOrder?: number; + metadata?: Record; +} + +/** + * Domain filters for querying + */ +export interface DomainFilters { + search?: string; + page?: number; + limit?: number; +} + +/** + * Fetch all domains + */ +export async function fetchDomains( + filters?: DomainFilters +): Promise> { + const params = new URLSearchParams(); + + if (filters?.search) { + params.append("search", filters.search); + } + if (filters?.page) { + params.append("page", filters.page.toString()); + } + if (filters?.limit) { + params.append("limit", filters.limit.toString()); + } + + const queryString = params.toString(); + const endpoint = queryString ? `/api/domains?${queryString}` : "/api/domains"; + + return apiGet>(endpoint); +} + +/** + * Fetch a single domain by ID + */ +export async function fetchDomain(id: string): Promise { + return apiGet(`/api/domains/${id}`); +} + +/** + * Create a new domain + */ +export async function createDomain(data: CreateDomainDto): Promise { + return apiPost("/api/domains", data); +} + +/** + * Update a domain + */ +export async function updateDomain( + id: string, + data: UpdateDomainDto +): Promise { + return apiPatch(`/api/domains/${id}`, data); +} + +/** + * Delete a domain + */ +export async function deleteDomain(id: string): Promise { + return apiDelete(`/api/domains/${id}`); +} diff --git a/packages/shared/src/types/database.types.ts b/packages/shared/src/types/database.types.ts index 2a19f78..b985032 100644 --- a/packages/shared/src/types/database.types.ts +++ b/packages/shared/src/types/database.types.ts @@ -183,3 +183,29 @@ export interface KnowledgeEntry extends BaseEntity { export interface KnowledgeEntryWithTags extends KnowledgeEntry { tags: KnowledgeTag[]; } + +/** + * Domain entity + */ +export interface Domain extends BaseEntity { + workspaceId: string; + name: string; + slug: string; + description: string | null; + color: string | null; + icon: string | null; + sortOrder: number; + metadata: Record; +} + +/** + * Domain with usage counts + */ +export interface DomainWithCounts extends Domain { + _count?: { + tasks: number; + projects: number; + events: number; + ideas: number; + }; +}