feat(#15): implement Gantt chart component

- Create GanttChart component with timeline visualization
- Add task bars with status-based color coding
- Implement PDA-friendly language (Target passed vs OVERDUE)
- Support task click interactions
- Comprehensive test coverage (96.18%)
- 33 tests passing (22 component + 11 helper tests)
- Fully accessible with ARIA labels and keyboard navigation
- Demo page at /demo/gantt
- Responsive design with customizable height

Technical details:
- Uses Next.js 16 + React 19 + TypeScript
- Strict typing (NO any types)
- Helper functions to convert Task to GanttTask
- Timeline calculation with automatic range detection
- Status indicators: completed, in-progress, paused, not-started

Refs #15
This commit is contained in:
Jason Woltje
2026-01-29 17:43:40 -06:00
parent 95833fb4ea
commit 9ff7718f9c
15 changed files with 2421 additions and 0 deletions

View 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);
});
});
});

View 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}`;
}
}

View 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 {}