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