Skip to content

Commit 88fa7c0

Browse files
committed
fixed enhance Devspace Overview with meaningful project-level performance metrics
Signed-off-by: RAWx18 <rawx18.dev@gmail.com>
1 parent d3e8e6a commit 88fa7c0

File tree

11 files changed

+1852
-1
lines changed

11 files changed

+1852
-1
lines changed

backend/src/api/devtel/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export default (app) => {
5252
`/tenant/:tenantId/devtel/projects/:projectId`,
5353
safeWrap(require('./projects/projectDestroy').default),
5454
)
55+
app.get(
56+
`/tenant/:tenantId/devtel/projects/:projectId/overview`,
57+
safeWrap(require('./projects/projectOverview').default),
58+
)
5559

5660
// ============================================
5761
// Issue Routes
@@ -166,6 +170,10 @@ export default (app) => {
166170
`/tenant/:tenantId/devtel/capacity/assignments/:assignmentId`,
167171
safeWrap(require('./capacity/assignmentUpdate').default),
168172
)
173+
app.get(
174+
`/tenant/:tenantId/devtel/projects/:projectId/capacity/contributions`,
175+
safeWrap(require('./capacity/contributionActivity').default),
176+
)
169177

170178
// ============================================
171179
// Spec Routes
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import Permissions from '../../../security/permissions'
2+
import PermissionChecker from '../../../services/user/permissionChecker'
3+
4+
/**
5+
* GET /tenant/{tenantId}/devtel/projects/{projectId}/overview
6+
* @summary Get comprehensive project overview metrics
7+
* @tag DevTel Projects
8+
* @security Bearer
9+
*/
10+
export default async (req, res) => {
11+
try {
12+
new PermissionChecker(req).validateHas(Permissions.values.memberRead)
13+
14+
const { projectId } = req.params
15+
const { days = 30 } = req.query
16+
17+
const daysAgo = new Date()
18+
daysAgo.setDate(daysAgo.getDate() - parseInt(days))
19+
20+
const previousPeriodStart = new Date(daysAgo)
21+
previousPeriodStart.setDate(previousPeriodStart.getDate() - parseInt(days))
22+
23+
// Get all issues for the project
24+
const allIssues = await req.database.devtelIssues.findAll({
25+
where: {
26+
projectId,
27+
deletedAt: null,
28+
},
29+
include: [
30+
{
31+
model: req.database.user,
32+
as: 'assignee',
33+
attributes: ['id', 'fullName', 'email'],
34+
},
35+
],
36+
})
37+
38+
// Get completed issues in current period
39+
const completedIssues = allIssues.filter(
40+
(issue) => issue.status === 'done' && new Date(issue.updatedAt) >= daysAgo
41+
)
42+
43+
// Get completed issues in previous period
44+
const previousCompletedIssues = allIssues.filter(
45+
(issue) =>
46+
issue.status === 'done' &&
47+
new Date(issue.updatedAt) >= previousPeriodStart &&
48+
new Date(issue.updatedAt) < daysAgo
49+
)
50+
51+
// Calculate velocity (story points per week)
52+
const totalPoints = completedIssues.reduce((sum, issue) => sum + (issue.storyPoints || 0), 0)
53+
const previousPoints = previousCompletedIssues.reduce((sum, issue) => sum + (issue.storyPoints || 0), 0)
54+
const weeksInPeriod = parseInt(days) / 7
55+
const velocity = totalPoints / weeksInPeriod
56+
const previousVelocity = previousPoints / weeksInPeriod
57+
const velocityTrend = previousVelocity > 0 ? ((velocity - previousVelocity) / previousVelocity) * 100 : 0
58+
59+
// Calculate average cycle time
60+
const issuesWithCycleTime = completedIssues.filter((issue) => issue.createdAt && issue.updatedAt)
61+
const avgCycleTime =
62+
issuesWithCycleTime.length > 0
63+
? issuesWithCycleTime.reduce((sum, issue) => {
64+
const cycleTime = (new Date(issue.updatedAt) - new Date(issue.createdAt)) / (1000 * 60 * 60 * 24)
65+
return sum + cycleTime
66+
}, 0) / issuesWithCycleTime.length
67+
: 0
68+
69+
const previousIssuesWithCycleTime = previousCompletedIssues.filter((issue) => issue.createdAt && issue.updatedAt)
70+
const previousAvgCycleTime =
71+
previousIssuesWithCycleTime.length > 0
72+
? previousIssuesWithCycleTime.reduce((sum, issue) => {
73+
const cycleTime = (new Date(issue.updatedAt) - new Date(issue.createdAt)) / (1000 * 60 * 60 * 24)
74+
return sum + cycleTime
75+
}, 0) / previousIssuesWithCycleTime.length
76+
: 0
77+
78+
const cycleTimeTrend =
79+
previousAvgCycleTime > 0 ? ((avgCycleTime - previousAvgCycleTime) / previousAvgCycleTime) * 100 : 0
80+
81+
// Calculate WIP
82+
const wipIssues = allIssues.filter((issue) => issue.status === 'in_progress')
83+
const wipCount = wipIssues.length
84+
85+
// Get project settings for WIP limit
86+
const project = await req.database.devtelProjects.findByPk(projectId)
87+
const wipLimit = project?.wipLimit || 0
88+
89+
// Calculate capacity metrics
90+
const teamMembers = await req.database.user.findAll({
91+
include: [
92+
{
93+
model: req.database.tenantUser,
94+
as: 'tenants',
95+
where: { tenantId: req.currentTenant.id },
96+
attributes: [],
97+
},
98+
],
99+
attributes: ['id', 'fullName'],
100+
})
101+
102+
const totalCapacity = teamMembers.length * 40 // 40 hours per week per person
103+
const allocatedHours = wipIssues.reduce((sum, issue) => sum + (issue.estimatedHours || 0), 0)
104+
const capacityUtilization = totalCapacity > 0 ? (allocatedHours / totalCapacity) * 100 : 0
105+
106+
// Active contributors
107+
const activeContributors = new Set(
108+
completedIssues.filter((issue) => issue.assigneeId).map((issue) => issue.assigneeId)
109+
).size
110+
111+
// Aging issues (in progress > 7 days)
112+
const sevenDaysAgo = new Date()
113+
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
114+
const agingIssues = wipIssues.filter((issue) => new Date(issue.updatedAt) < sevenDaysAgo).length
115+
116+
const fourteenDaysAgo = new Date()
117+
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14)
118+
const stalledIssues = wipIssues.filter((issue) => new Date(issue.updatedAt) < fourteenDaysAgo).length
119+
120+
// Planned vs actual (from current cycle)
121+
const activeCycle = await req.database.devtelCycles.findOne({
122+
where: {
123+
projectId,
124+
status: 'active',
125+
deletedAt: null,
126+
},
127+
})
128+
129+
let plannedVsActual = 0
130+
let completedPlanned = 0
131+
let totalPlanned = 0
132+
133+
if (activeCycle) {
134+
const cycleIssues = allIssues.filter((issue) => issue.cycleId === activeCycle.id)
135+
totalPlanned = cycleIssues.length
136+
completedPlanned = cycleIssues.filter((issue) => issue.status === 'done').length
137+
plannedVsActual = totalPlanned > 0 ? (completedPlanned / totalPlanned) * 100 : 0
138+
}
139+
140+
// Delivery trend data (weekly breakdown)
141+
const deliveryTrend = []
142+
const currentDate = new Date(daysAgo)
143+
while (currentDate <= new Date()) {
144+
const weekStart = new Date(currentDate)
145+
const weekEnd = new Date(currentDate)
146+
weekEnd.setDate(weekEnd.getDate() + 7)
147+
148+
const weekCompleted = completedIssues.filter(
149+
(issue) => new Date(issue.updatedAt) >= weekStart && new Date(issue.updatedAt) < weekEnd
150+
)
151+
152+
const weekPoints = weekCompleted.reduce((sum, issue) => sum + (issue.storyPoints || 0), 0)
153+
154+
deliveryTrend.push({
155+
date: weekStart.toISOString().split('T')[0],
156+
completed: weekPoints,
157+
planned: activeCycle ? activeCycle.plannedPoints / (parseInt(days) / 7) : 0,
158+
})
159+
160+
currentDate.setDate(currentDate.getDate() + 7)
161+
}
162+
163+
// Cycle time distribution
164+
const cycleTimeDistribution = [
165+
{ range: '0-2 days', count: 0 },
166+
{ range: '3-5 days', count: 0 },
167+
{ range: '6-10 days', count: 0 },
168+
{ range: '11-20 days', count: 0 },
169+
{ range: '20+ days', count: 0 },
170+
]
171+
172+
issuesWithCycleTime.forEach((issue) => {
173+
const cycleTime = (new Date(issue.updatedAt) - new Date(issue.createdAt)) / (1000 * 60 * 60 * 24)
174+
if (cycleTime <= 2) cycleTimeDistribution[0].count++
175+
else if (cycleTime <= 5) cycleTimeDistribution[1].count++
176+
else if (cycleTime <= 10) cycleTimeDistribution[2].count++
177+
else if (cycleTime <= 20) cycleTimeDistribution[3].count++
178+
else cycleTimeDistribution[4].count++
179+
})
180+
181+
// Contributor load balance
182+
const contributorLoad = []
183+
const assigneeMap = {}
184+
185+
wipIssues.forEach((issue) => {
186+
if (issue.assigneeId) {
187+
if (!assigneeMap[issue.assigneeId]) {
188+
assigneeMap[issue.assigneeId] = {
189+
name: issue.assignee?.fullName || 'Unknown',
190+
assigned: 0,
191+
capacity: 40,
192+
}
193+
}
194+
assigneeMap[issue.assigneeId].assigned += issue.estimatedHours || 0
195+
}
196+
})
197+
198+
Object.values(assigneeMap).forEach((contributor: any) => {
199+
contributorLoad.push(contributor)
200+
})
201+
202+
// Issue aging analysis
203+
const issueAging = [
204+
{ range: '0-3 days', count: 0 },
205+
{ range: '4-7 days', count: 0 },
206+
{ range: '8-14 days', count: 0 },
207+
{ range: '14+ days', count: 0 },
208+
]
209+
210+
const now = new Date()
211+
wipIssues.forEach((issue) => {
212+
const age = (now - new Date(issue.updatedAt)) / (1000 * 60 * 60 * 24)
213+
if (age <= 3) issueAging[0].count++
214+
else if (age <= 7) issueAging[1].count++
215+
else if (age <= 14) issueAging[2].count++
216+
else issueAging[3].count++
217+
})
218+
219+
const overview = {
220+
// Primary metrics
221+
velocity: parseFloat(velocity.toFixed(1)),
222+
velocityTrend: parseFloat(velocityTrend.toFixed(1)),
223+
avgCycleTime: parseFloat(avgCycleTime.toFixed(1)),
224+
cycleTimeTrend: parseFloat(cycleTimeTrend.toFixed(1)),
225+
wipCount,
226+
wipLimit,
227+
wipTrend: 0, // Could calculate based on historical data
228+
capacityUtilization: parseFloat(capacityUtilization.toFixed(1)),
229+
capacityTrend: 0, // Could calculate based on historical data
230+
allocatedHours,
231+
totalCapacity,
232+
233+
// Secondary metrics
234+
throughput: completedIssues.length,
235+
activeContributors,
236+
agingIssues,
237+
stalledIssues,
238+
plannedVsActual: parseFloat(plannedVsActual.toFixed(1)),
239+
completedPlanned,
240+
totalPlanned,
241+
completedIssues: completedIssues.length,
242+
243+
// Chart data
244+
deliveryTrend,
245+
cycleTimeDistribution,
246+
contributorLoad,
247+
issueAging,
248+
}
249+
250+
await req.responseHandler.success(req, res, overview)
251+
} catch (error) {
252+
console.error('Project Overview Error:', error.message)
253+
throw error
254+
}
255+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Add WIP limit field to devtel projects
2+
ALTER TABLE "devtelProjects" ADD COLUMN IF NOT EXISTS "wipLimit" INTEGER DEFAULT 0;
3+
4+
-- Add estimated hours field to issues for capacity planning
5+
ALTER TABLE "devtelIssues" ADD COLUMN IF NOT EXISTS "estimatedHours" DECIMAL(10,2) DEFAULT 0;
6+
7+
-- Add planned points field to cycles
8+
ALTER TABLE "devtelCycles" ADD COLUMN IF NOT EXISTS "plannedPoints" INTEGER DEFAULT 0;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<template>
2+
<div class="chart-container">
3+
<div v-if="loading" class="chart-loading">
4+
<el-skeleton :rows="3" animated />
5+
</div>
6+
<div v-else-if="!data || data.length === 0" class="chart-empty">
7+
<el-empty description="No data available" :image-size="80" />
8+
</div>
9+
<canvas v-else ref="chartRef" style="max-height: 250px;"></canvas>
10+
</div>
11+
</template>
12+
13+
<script setup>
14+
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
15+
import { Chart, registerables } from 'chart.js';
16+
17+
Chart.register(...registerables);
18+
19+
const props = defineProps({
20+
data: { type: Array, default: () => [] },
21+
loading: { type: Boolean, default: false },
22+
});
23+
24+
const chartRef = ref(null);
25+
let chartInstance = null;
26+
27+
const initChart = () => {
28+
if (!chartRef.value || props.loading || !props.data || props.data.length === 0) return;
29+
30+
if (chartInstance) {
31+
chartInstance.destroy();
32+
}
33+
34+
const ctx = chartRef.value.getContext('2d');
35+
36+
chartInstance = new Chart(ctx, {
37+
type: 'bar',
38+
data: {
39+
labels: props.data.map(d => d.name),
40+
datasets: [
41+
{
42+
label: 'Assigned',
43+
data: props.data.map(d => d.assigned),
44+
backgroundColor: '#409eff',
45+
},
46+
{
47+
label: 'Capacity',
48+
data: props.data.map(d => d.capacity),
49+
backgroundColor: '#67c23a',
50+
},
51+
],
52+
},
53+
options: {
54+
responsive: true,
55+
maintainAspectRatio: false,
56+
plugins: {
57+
legend: {
58+
display: true,
59+
position: 'top',
60+
labels: { color: '#a1a1aa' },
61+
},
62+
tooltip: {
63+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
64+
borderColor: '#3f3f46',
65+
borderWidth: 1,
66+
},
67+
},
68+
scales: {
69+
y: {
70+
beginAtZero: true,
71+
ticks: { color: '#a1a1aa' },
72+
grid: { color: '#27272a' },
73+
},
74+
x: {
75+
ticks: { color: '#a1a1aa', maxRotation: 30, minRotation: 30 },
76+
grid: { color: '#27272a' },
77+
},
78+
},
79+
},
80+
});
81+
};
82+
83+
watch(() => [props.data, props.loading], () => {
84+
nextTick(() => initChart());
85+
}, { deep: true });
86+
87+
onMounted(() => {
88+
nextTick(() => initChart());
89+
});
90+
91+
onUnmounted(() => {
92+
if (chartInstance) {
93+
chartInstance.destroy();
94+
}
95+
});
96+
</script>
97+
98+
<style scoped>
99+
.chart-container {
100+
width: 100%;
101+
height: 250px;
102+
}
103+
104+
.chart-loading,
105+
.chart-empty {
106+
display: flex;
107+
align-items: center;
108+
justify-content: center;
109+
height: 250px;
110+
}
111+
</style>

0 commit comments

Comments
 (0)