Merge feature/15-gantt-chart (#15) into develop
Implements Gantt chart component: - Task visualization with timeline bars - PDA-friendly language (Target passed, not OVERDUE) - 33 tests, 96% coverage - Accessible with ARIA labels - Demo page at /demo/gantt
This commit is contained in:
220
apps/api/src/domains/domains.controller.spec.ts
Normal file
220
apps/api/src/domains/domains.controller.spec.ts
Normal file
@@ -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>(DomainsController);
|
||||
service = module.get<DomainsService>(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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
393
apps/api/src/domains/domains.service.spec.ts
Normal file
393
apps/api/src/domains/domains.service.spec.ts
Normal file
@@ -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>(DomainsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
activityService = module.get<ActivityService>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>(EventsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
activityService = module.get<ActivityService>(ActivityService);
|
||||
wsGateway = module.get<WebSocketGateway>(WebSocketGateway);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -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>(ProjectsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
activityService = module.get<ActivityService>(ActivityService);
|
||||
wsGateway = module.get<WebSocketGateway>(WebSocketGateway);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
175
apps/api/src/websocket/websocket.gateway.spec.ts
Normal file
175
apps/api/src/websocket/websocket.gateway.spec.ts
Normal file
@@ -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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
153
apps/api/src/websocket/websocket.gateway.ts
Normal file
153
apps/api/src/websocket/websocket.gateway.ts
Normal file
@@ -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<void> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
11
apps/api/src/websocket/websocket.module.ts
Normal file
11
apps/api/src/websocket/websocket.module.ts
Normal file
@@ -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 {}
|
||||
316
apps/web/src/app/demo/gantt/page.tsx
Normal file
316
apps/web/src/app/demo/gantt/page.tsx
Normal file
@@ -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<GanttTask | null>(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 (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Gantt Chart Component Demo
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Interactive project timeline visualization with task dependencies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-2xl font-bold text-gray-900">{statusCounts.total}</div>
|
||||
<div className="text-sm text-gray-600">Total Tasks</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<div className="text-2xl font-bold text-green-700">{statusCounts.completed}</div>
|
||||
<div className="text-sm text-green-600">Completed</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<div className="text-2xl font-bold text-blue-700">{statusCounts.inProgress}</div>
|
||||
<div className="text-sm text-blue-600">In Progress</div>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-4 rounded-lg border border-gray-300">
|
||||
<div className="text-2xl font-bold text-gray-700">{statusCounts.notStarted}</div>
|
||||
<div className="text-sm text-gray-600">Not Started</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
|
||||
<div className="text-2xl font-bold text-yellow-700">{statusCounts.paused}</div>
|
||||
<div className="text-sm text-yellow-600">Paused</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDependencies}
|
||||
onChange={(e) => setShowDependencies(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Show Dependencies (coming soon)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gantt Chart */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden mb-6">
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Project Timeline
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<GanttChart
|
||||
tasks={ganttTasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
height={500}
|
||||
showDependencies={showDependencies}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Task Details */}
|
||||
{selectedTask && (
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Selected Task Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Title</div>
|
||||
<div className="text-gray-900">{selectedTask.title}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Status</div>
|
||||
<div className="text-gray-900">{selectedTask.status}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Priority</div>
|
||||
<div className="text-gray-900">{selectedTask.priority}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Duration</div>
|
||||
<div className="text-gray-900">
|
||||
{Math.ceil(
|
||||
(selectedTask.endDate.getTime() - selectedTask.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
)}{" "}
|
||||
days
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Start Date</div>
|
||||
<div className="text-gray-900">
|
||||
{selectedTask.startDate.toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">End Date</div>
|
||||
<div className="text-gray-900">
|
||||
{selectedTask.endDate.toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTask.description && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Description</div>
|
||||
<div className="text-gray-900">{selectedTask.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDA-Friendly Language Notice */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">
|
||||
🌟 PDA-Friendly Design
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
401
apps/web/src/components/gantt/GanttChart.test.tsx
Normal file
401
apps/web/src/components/gantt/GanttChart.test.tsx
Normal file
@@ -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> = {}): 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(<GanttChart tasks={[]} />);
|
||||
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(<GanttChart tasks={tasks} />);
|
||||
|
||||
// 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(<GanttChart tasks={tasks} />);
|
||||
|
||||
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(<GanttChart tasks={tasks} />);
|
||||
|
||||
// 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(<GanttChart tasks={tasks} />);
|
||||
|
||||
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(<GanttChart tasks={tasks} />);
|
||||
|
||||
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(<GanttChart tasks={[pastTask]} />);
|
||||
|
||||
// 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(<GanttChart tasks={[soonTask]} />);
|
||||
|
||||
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(<GanttChart tasks={[task]} onTaskClick={onTaskClick} />);
|
||||
|
||||
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(<GanttChart tasks={[task]} />);
|
||||
|
||||
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(<GanttChart tasks={tasks} />);
|
||||
|
||||
// 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(<GanttChart tasks={tasks} />);
|
||||
|
||||
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(<GanttChart tasks={[]} />);
|
||||
|
||||
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(<GanttChart tasks={[task]} />);
|
||||
|
||||
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(<GanttChart tasks={[task]} onTaskClick={onTaskClick} />);
|
||||
|
||||
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(<GanttChart tasks={tasks} height={600} />);
|
||||
|
||||
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(<GanttChart tasks={tasks} />);
|
||||
|
||||
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(<GanttChart tasks={[task]} />);
|
||||
|
||||
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(<GanttChart tasks={[task]} />);
|
||||
|
||||
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(<GanttChart tasks={tasks} />);
|
||||
|
||||
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(<GanttChart tasks={tasks} showDependencies={true} />);
|
||||
|
||||
// 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(<GanttChart tasks={tasks} />);
|
||||
|
||||
// Dependencies should not be shown by default
|
||||
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
||||
expect(chart).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
299
apps/web/src/components/gantt/GanttChart.tsx
Normal file
299
apps/web/src/components/gantt/GanttChart.tsx
Normal file
@@ -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<HTMLDivElement>): void => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (onTaskClick) {
|
||||
onTaskClick(task);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Gantt Chart"
|
||||
className="gantt-chart bg-white rounded-lg border border-gray-200 overflow-hidden"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
<div className="gantt-container flex h-full">
|
||||
{/* Task list column */}
|
||||
<div className="gantt-task-list w-64 border-r border-gray-200 overflow-y-auto flex-shrink-0">
|
||||
<div className="gantt-task-list-header bg-gray-50 p-3 border-b border-gray-200 font-semibold sticky top-0 z-10">
|
||||
Tasks
|
||||
</div>
|
||||
<div className="gantt-task-list-body">
|
||||
{sortedTasks.map((task, index) => {
|
||||
const isPast = isPastTarget(task.endDate);
|
||||
const isApproaching = !isPast && isApproachingTarget(task.endDate);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
role="row"
|
||||
className={`gantt-task-row p-3 border-b border-gray-100 h-12 flex items-center ${getRowStatusClass(
|
||||
task.status
|
||||
)}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{task.title}
|
||||
</div>
|
||||
{isPast && task.status !== TaskStatus.COMPLETED && (
|
||||
<div className="text-xs text-amber-600">Target passed</div>
|
||||
)}
|
||||
{isApproaching && task.status !== TaskStatus.COMPLETED && (
|
||||
<div className="text-xs text-orange-600">Approaching target</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline column */}
|
||||
<div className="gantt-timeline flex-1 overflow-x-auto overflow-y-auto">
|
||||
<div role="region" aria-label="Timeline" className="gantt-timeline-container min-w-full">
|
||||
{/* Timeline header */}
|
||||
<div className="gantt-timeline-header bg-gray-50 border-b border-gray-200 h-12 sticky top-0 z-10 relative">
|
||||
{timelineLabels.map((label, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute top-0 bottom-0 flex items-center text-xs text-gray-600 px-2"
|
||||
style={{ left: `${label.position}%` }}
|
||||
>
|
||||
{label.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline grid and bars */}
|
||||
<div className="gantt-timeline-body relative">
|
||||
{/* Grid lines */}
|
||||
<div className="gantt-grid absolute inset-0 pointer-events-none">
|
||||
{timelineLabels.map((label, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute top-0 bottom-0 w-px bg-gray-200"
|
||||
style={{ left: `${label.position}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task bars */}
|
||||
{sortedTasks.map((task, index) => {
|
||||
const position = calculateBarPosition(task, timelineRange, index);
|
||||
const statusClass = getStatusClass(task.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Gantt bar for ${task.title}, from ${formatDate(
|
||||
task.startDate
|
||||
)} to ${formatDate(task.endDate)}`}
|
||||
className={`gantt-bar absolute h-8 rounded cursor-pointer transition-all hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500 ${statusClass}`}
|
||||
style={{
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
top: `${position.top + 8}px`, // Center in row
|
||||
}}
|
||||
onClick={handleTaskClick(task)}
|
||||
onKeyDown={handleKeyDown(task)}
|
||||
>
|
||||
<div className="px-2 text-xs text-white truncate leading-8">
|
||||
{task.title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer for scrolling */}
|
||||
<div
|
||||
style={{
|
||||
height: `${sortedTasks.length * 48}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS for status classes */}
|
||||
<style jsx>{`
|
||||
.gantt-row-completed {
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
.gantt-row-in-progress {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
.gantt-row-paused {
|
||||
background-color: #fefce8;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/components/gantt/index.ts
Normal file
7
apps/web/src/components/gantt/index.ts
Normal file
@@ -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";
|
||||
204
apps/web/src/components/gantt/types.test.ts
Normal file
204
apps/web/src/components/gantt/types.test.ts
Normal file
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
95
apps/web/src/components/gantt/types.ts
Normal file
95
apps/web/src/components/gantt/types.ts
Normal file
@@ -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);
|
||||
}
|
||||
97
apps/web/src/lib/api/domains.ts
Normal file
97
apps/web/src/lib/api/domains.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update domain DTO
|
||||
*/
|
||||
export interface UpdateDomainDto {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
sortOrder?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain filters for querying
|
||||
*/
|
||||
export interface DomainFilters {
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all domains
|
||||
*/
|
||||
export async function fetchDomains(
|
||||
filters?: DomainFilters
|
||||
): Promise<ApiResponse<Domain[]>> {
|
||||
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<ApiResponse<Domain[]>>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single domain by ID
|
||||
*/
|
||||
export async function fetchDomain(id: string): Promise<DomainWithCounts> {
|
||||
return apiGet<DomainWithCounts>(`/api/domains/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new domain
|
||||
*/
|
||||
export async function createDomain(data: CreateDomainDto): Promise<Domain> {
|
||||
return apiPost<Domain>("/api/domains", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a domain
|
||||
*/
|
||||
export async function updateDomain(
|
||||
id: string,
|
||||
data: UpdateDomainDto
|
||||
): Promise<Domain> {
|
||||
return apiPatch<Domain>(`/api/domains/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a domain
|
||||
*/
|
||||
export async function deleteDomain(id: string): Promise<void> {
|
||||
return apiDelete<void>(`/api/domains/${id}`);
|
||||
}
|
||||
Reference in New Issue
Block a user