diff --git a/src/app/(payload)/api/youtube/capacity/route.ts b/src/app/(payload)/api/youtube/capacity/route.ts new file mode 100644 index 0000000..a729247 --- /dev/null +++ b/src/app/(payload)/api/youtube/capacity/route.ts @@ -0,0 +1,95 @@ +// src/app/(payload)/api/youtube/capacity/route.ts + +import { getPayload } from 'payload' +import config from '@payload-config' +import { NextRequest, NextResponse } from 'next/server' +import { calculateCapacity, type CapacityInput } from '@/lib/youtube/capacity-calculator' + +interface UserWithYouTubeRole { + id: number + email?: string + isSuperAdmin?: boolean + youtubeRole?: string +} + +const DEFAULT_HOURS_PER_TASK = 2 +const DEFAULT_AVAILABLE_HOURS_PER_WEEK = 40 + +/** + * GET /api/youtube/capacity + * + * Returns team capacity data for all users with a YouTube role. + * Calculates utilization based on active tasks and estimated hours. + */ +export async function GET(req: NextRequest) { + try { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Find all users with a YouTube role + const users = await payload.find({ + collection: 'users', + where: { + youtubeRole: { exists: true }, + }, + limit: 50, + depth: 0, + }) + + const capacities = await Promise.all( + users.docs.map(async (u) => { + const typedUser = u as unknown as UserWithYouTubeRole + + // Count active tasks + const tasks = await payload.count({ + collection: 'yt-tasks', + where: { + assignedTo: { equals: typedUser.id }, + status: { in: ['todo', 'in_progress'] }, + }, + }) + + // Count videos in pipeline (exclude terminal and initial states) + const videos = await payload.count({ + collection: 'youtube-content', + where: { + assignedTo: { equals: typedUser.id }, + status: { not_in: ['published', 'tracked', 'discarded', 'idea'] }, + }, + }) + + const input: CapacityInput = { + userId: typedUser.id, + userName: typedUser.email ?? `User ${typedUser.id}`, + activeTasks: tasks.totalDocs, + estimatedHours: tasks.totalDocs * DEFAULT_HOURS_PER_TASK, + videosInPipeline: videos.totalDocs, + availableHoursPerWeek: DEFAULT_AVAILABLE_HOURS_PER_WEEK, + } + + return calculateCapacity(input) + }), + ) + + return NextResponse.json({ + success: true, + team: capacities, + summary: { + totalMembers: capacities.length, + overloaded: capacities.filter((c) => c.status === 'red').length, + atCapacity: capacities.filter((c) => c.status === 'yellow').length, + available: capacities.filter((c) => c.status === 'green').length, + }, + }) + } catch (error) { + console.error('[Capacity API] Error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} diff --git a/src/lib/youtube/capacity-calculator.ts b/src/lib/youtube/capacity-calculator.ts new file mode 100644 index 0000000..9f7a9fd --- /dev/null +++ b/src/lib/youtube/capacity-calculator.ts @@ -0,0 +1,44 @@ +/** + * Team Capacity Calculator + * + * Pure utility for calculating team member workload utilization + * and capacity status used by the capacity API route. + */ + +export interface CapacityInput { + userId: number + userName: string + activeTasks: number + estimatedHours: number + videosInPipeline: number + availableHoursPerWeek: number +} + +export interface CapacityResult { + userId: number + userName: string + activeTasks: number + estimatedHours: number + videosInPipeline: number + availableHoursPerWeek: number + utilization: number // 0-100+ + status: 'green' | 'yellow' | 'red' +} + +function calculateCapacity(input: CapacityInput): CapacityResult { + const utilization = + input.availableHoursPerWeek > 0 + ? Math.round((input.estimatedHours / input.availableHoursPerWeek) * 100) + : 0 + + let status: CapacityResult['status'] = 'green' + if (utilization > 90) { + status = 'red' + } else if (utilization > 70) { + status = 'yellow' + } + + return { ...input, utilization, status } +} + +export { calculateCapacity } diff --git a/tests/unit/youtube/capacity.unit.spec.ts b/tests/unit/youtube/capacity.unit.spec.ts new file mode 100644 index 0000000..390cd89 --- /dev/null +++ b/tests/unit/youtube/capacity.unit.spec.ts @@ -0,0 +1,143 @@ +/** + * Team Capacity Calculator Unit Tests + * + * Tests utilization calculation and status thresholds + * used by the capacity API route. + */ + +import { describe, it, expect } from 'vitest' +import { calculateCapacity, type CapacityInput } from '@/lib/youtube/capacity-calculator' + +describe('Capacity Calculator', () => { + it('should calculate utilization percentage', () => { + const input: CapacityInput = { + userId: 1, + userName: 'Max', + activeTasks: 5, + estimatedHours: 20, + videosInPipeline: 3, + availableHoursPerWeek: 40, + } + + const result = calculateCapacity(input) + + expect(result.utilization).toBe(50) + expect(result.status).toBe('green') + }) + + it('should flag overloaded team members as red', () => { + const input: CapacityInput = { + userId: 2, + userName: 'Anna', + activeTasks: 10, + estimatedHours: 38, + videosInPipeline: 8, + availableHoursPerWeek: 40, + } + + const result = calculateCapacity(input) + + expect(result.utilization).toBe(95) + expect(result.status).toBe('red') + }) + + it('should flag busy team members as yellow', () => { + const input: CapacityInput = { + userId: 3, + userName: 'Lisa', + activeTasks: 8, + estimatedHours: 32, + videosInPipeline: 5, + availableHoursPerWeek: 40, + } + + const result = calculateCapacity(input) + + expect(result.utilization).toBe(80) + expect(result.status).toBe('yellow') + }) + + it('should handle zero available hours', () => { + const input: CapacityInput = { + userId: 4, + userName: 'Tom', + activeTasks: 3, + estimatedHours: 10, + videosInPipeline: 2, + availableHoursPerWeek: 0, + } + + const result = calculateCapacity(input) + + expect(result.utilization).toBe(0) + expect(result.status).toBe('green') + }) + + it('should preserve all input fields in the result', () => { + const input: CapacityInput = { + userId: 5, + userName: 'Sara', + activeTasks: 4, + estimatedHours: 16, + videosInPipeline: 2, + availableHoursPerWeek: 40, + } + + const result = calculateCapacity(input) + + expect(result.userId).toBe(5) + expect(result.userName).toBe('Sara') + expect(result.activeTasks).toBe(4) + expect(result.estimatedHours).toBe(16) + expect(result.videosInPipeline).toBe(2) + expect(result.availableHoursPerWeek).toBe(40) + }) + + it('should return red for utilization over 100%', () => { + const input: CapacityInput = { + userId: 6, + userName: 'Overloaded', + activeTasks: 30, + estimatedHours: 60, + videosInPipeline: 10, + availableHoursPerWeek: 40, + } + + const result = calculateCapacity(input) + + expect(result.utilization).toBe(150) + expect(result.status).toBe('red') + }) + + it('should return green at exactly 70% utilization', () => { + const input: CapacityInput = { + userId: 7, + userName: 'Boundary70', + activeTasks: 7, + estimatedHours: 28, + videosInPipeline: 3, + availableHoursPerWeek: 40, + } + + const result = calculateCapacity(input) + + expect(result.utilization).toBe(70) + expect(result.status).toBe('green') + }) + + it('should return yellow at exactly 90% utilization', () => { + const input: CapacityInput = { + userId: 8, + userName: 'Boundary90', + activeTasks: 9, + estimatedHours: 36, + videosInPipeline: 4, + availableHoursPerWeek: 40, + } + + const result = calculateCapacity(input) + + expect(result.utilization).toBe(90) + expect(result.status).toBe('yellow') + }) +})