mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
feat(youtube): add team capacity planning API
Adds a capacity calculator utility and API endpoint that computes workload utilization for team members with YouTube roles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2ea42ca404
commit
d8118528db
3 changed files with 282 additions and 0 deletions
95
src/app/(payload)/api/youtube/capacity/route.ts
Normal file
95
src/app/(payload)/api/youtube/capacity/route.ts
Normal file
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/lib/youtube/capacity-calculator.ts
Normal file
44
src/lib/youtube/capacity-calculator.ts
Normal file
|
|
@ -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 }
|
||||||
143
tests/unit/youtube/capacity.unit.spec.ts
Normal file
143
tests/unit/youtube/capacity.unit.spec.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue