mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24: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