|
| 1 | +const { SCHEMA_TYPE } = require('../../Config/schemaType'); |
| 2 | +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); |
| 3 | +const mongoose = require('mongoose'); |
| 4 | +const logger = require('../../Config/loggerConfig'); |
| 5 | + |
| 6 | +// Sprint burndown. Completion is derived from the data that already exists: |
| 7 | +// a task counts as done when its current status type is 'close', and its |
| 8 | +// completion date is the createdAt of its latest Task_Status history entry |
| 9 | +// (fallback: the task's updatedAt). No new write paths — read-only endpoint. |
| 10 | + |
| 11 | +const MAX_DAYS = 120; |
| 12 | +const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/; |
| 13 | + |
| 14 | +const endOfDay = (date) => { |
| 15 | + const d = new Date(date); |
| 16 | + d.setHours(23, 59, 59, 999); |
| 17 | + return d; |
| 18 | +}; |
| 19 | + |
| 20 | +const dayKey = (date) => { |
| 21 | + const d = new Date(date); |
| 22 | + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; |
| 23 | +}; |
| 24 | + |
| 25 | +/* POST /api/v2/sprints/burndown body: { sprintId } (companyId from header) */ |
| 26 | +exports.getSprintBurndown = async (req, res) => { |
| 27 | + try { |
| 28 | + const companyId = req.headers['companyid'] || ''; |
| 29 | + const { sprintId } = req.body || {}; |
| 30 | + if (!companyId || !OBJECT_ID_PATTERN.test(String(sprintId || ''))) { |
| 31 | + return res.send({ status: false, statusText: 'companyId and a valid sprintId are required.' }); |
| 32 | + } |
| 33 | + |
| 34 | + const sprintObjId = new mongoose.Types.ObjectId(sprintId); |
| 35 | + const sprint = await MongoDbCrudOpration(companyId, { |
| 36 | + type: SCHEMA_TYPE.SPRINTS, |
| 37 | + data: [{ _id: sprintObjId }], |
| 38 | + }, 'findOne'); |
| 39 | + if (!sprint) { |
| 40 | + return res.send({ status: false, statusText: 'Sprint not found.' }); |
| 41 | + } |
| 42 | + |
| 43 | + // Active + archived tasks count toward sprint scope; deleted don't. |
| 44 | + const tasks = await MongoDbCrudOpration(companyId, { |
| 45 | + type: SCHEMA_TYPE.TASKS, |
| 46 | + data: [ |
| 47 | + { sprintId: sprintObjId, deletedStatusKey: { $in: [0, 2, undefined] }, isParentTask: true }, |
| 48 | + '_id TaskKey statusType totalEstimatedTime createdAt updatedAt', |
| 49 | + ], |
| 50 | + }, 'find'); |
| 51 | + |
| 52 | + if (!tasks || !tasks.length) { |
| 53 | + return res.send({ status: true, statusText: 'No tasks in this sprint yet.', data: { sprintName: sprint.name || '', days: [] } }); |
| 54 | + } |
| 55 | + |
| 56 | + // Latest status-change date per task. History stores TaskId in |
| 57 | + // whichever form the writer passed, so match both string + ObjectId. |
| 58 | + const idStrings = tasks.map((task) => String(task._id)); |
| 59 | + const idObjects = tasks.map((task) => task._id); |
| 60 | + const historyRows = await MongoDbCrudOpration(companyId, { |
| 61 | + type: SCHEMA_TYPE.HISTORY, |
| 62 | + data: [ |
| 63 | + { Key: 'Task_Status', TaskId: { $in: [...idStrings, ...idObjects] } }, |
| 64 | + 'TaskId createdAt', |
| 65 | + ], |
| 66 | + }, 'find'); |
| 67 | + |
| 68 | + const lastStatusChange = new Map(); |
| 69 | + (historyRows || []).forEach((row) => { |
| 70 | + const key = String(row.TaskId); |
| 71 | + const current = lastStatusChange.get(key); |
| 72 | + if (!current || new Date(row.createdAt) > current) { |
| 73 | + lastStatusChange.set(key, new Date(row.createdAt)); |
| 74 | + } |
| 75 | + }); |
| 76 | + |
| 77 | + const enriched = tasks.map((task) => ({ |
| 78 | + createdAt: new Date(task.createdAt), |
| 79 | + estimate: Number(task.totalEstimatedTime) || 0, |
| 80 | + completedAt: task.statusType === 'close' |
| 81 | + ? (lastStatusChange.get(String(task._id)) || new Date(task.updatedAt)) |
| 82 | + : null, |
| 83 | + })); |
| 84 | + |
| 85 | + // Date range: sprint start (or earliest task) → today, capped. |
| 86 | + const earliestTask = enriched.reduce((min, task) => (task.createdAt < min ? task.createdAt : min), new Date()); |
| 87 | + let rangeStart = sprint.startDate ? new Date(sprint.startDate) : (sprint.createdAt ? new Date(sprint.createdAt) : earliestTask); |
| 88 | + if (earliestTask < rangeStart) rangeStart = earliestTask; |
| 89 | + const rangeEnd = new Date(); |
| 90 | + const totalDays = Math.min(MAX_DAYS, Math.max(1, Math.ceil((endOfDay(rangeEnd) - rangeStart) / 86400000))); |
| 91 | + |
| 92 | + const totalCount = enriched.length; |
| 93 | + const totalEstimate = enriched.reduce((sum, task) => sum + task.estimate, 0); |
| 94 | + const days = []; |
| 95 | + for (let i = 0; i < totalDays; i++) { |
| 96 | + const cursor = endOfDay(new Date(rangeStart.getTime() + i * 86400000)); |
| 97 | + const scoped = enriched.filter((task) => task.createdAt <= cursor); |
| 98 | + const completed = scoped.filter((task) => task.completedAt && task.completedAt <= cursor); |
| 99 | + const completedEstimate = completed.reduce((sum, task) => sum + task.estimate, 0); |
| 100 | + const scopedEstimate = scoped.reduce((sum, task) => sum + task.estimate, 0); |
| 101 | + days.push({ |
| 102 | + date: dayKey(cursor), |
| 103 | + remainingCount: scoped.length - completed.length, |
| 104 | + remainingEstimate: Math.max(0, scopedEstimate - completedEstimate), |
| 105 | + // Straight line from full scope on day one to zero on the last day. |
| 106 | + ideal: Math.max(0, Math.round((totalCount * (totalDays - 1 - i)) / Math.max(1, totalDays - 1))), |
| 107 | + }); |
| 108 | + } |
| 109 | + |
| 110 | + return res.send({ |
| 111 | + status: true, |
| 112 | + statusText: 'Burndown computed.', |
| 113 | + data: { |
| 114 | + sprintName: sprint.name || '', |
| 115 | + totalCount, |
| 116 | + totalEstimate, |
| 117 | + days, |
| 118 | + }, |
| 119 | + }); |
| 120 | + } catch (error) { |
| 121 | + logger.error(`ERROR in sprint burndown: ${error.message}`); |
| 122 | + return res.send({ status: false, statusText: error.message }); |
| 123 | + } |
| 124 | +}; |
0 commit comments