Skip to content

Commit 4d0289b

Browse files
authored
Merge pull request #255 from aliansoftwareteam/feat/sprint4-implementation
feat(sprint4): agile reports — burndown, velocity, CFD, PDF export
2 parents 5b11989 + 0d972ef commit 4d0289b

20 files changed

Lines changed: 1173 additions & 18 deletions

File tree

Modules/AgileReports/cfd.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Cumulative flow diagram (S4-03). Task count per status-category per day at
2+
// project level, over a date range (default last 30 days). Computed on-the-fly:
3+
// a task's completion day comes from its latest Task_Status history entry (same
4+
// signal the burndown uses); before completion a task is attributed to its
5+
// current status-category band. Bands are statusType categories (open /
6+
// inprogress / onhold / close) — reliably reconstructable, unlike per-custom-
7+
// status history which the history log does not store structurally.
8+
const { SCHEMA_TYPE } = require('../../Config/schemaType');
9+
const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries');
10+
const mongoose = require('mongoose');
11+
const logger = require('../../Config/loggerConfig');
12+
13+
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
14+
const MAX_DAYS = 120;
15+
const BANDS = ['open', 'inprogress', 'onhold', 'close'];
16+
17+
const endOfDay = (date) => { const d = new Date(date); d.setHours(23, 59, 59, 999); return d; };
18+
const startOfDay = (date) => { const d = new Date(date); d.setHours(0, 0, 0, 0); return d; };
19+
const dayKey = (date) => {
20+
const d = new Date(date);
21+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
22+
};
23+
24+
/* GET /api/v1/agile/cfd?projectId=&from=&to= (companyId from header) */
25+
exports.getCFD = async (req, res) => {
26+
try {
27+
const companyId = req.headers['companyid'] || '';
28+
const projectId = String((req.query && req.query.projectId) || '');
29+
if (!companyId || !OBJECT_ID_PATTERN.test(projectId)) {
30+
return res.send({ status: false, statusText: 'companyId and a valid projectId are required.' });
31+
}
32+
const projectObjId = new mongoose.Types.ObjectId(projectId);
33+
34+
const rangeEnd = endOfDay(req.query && req.query.to ? new Date(req.query.to) : new Date());
35+
let rangeStart = startOfDay(req.query && req.query.from ? new Date(req.query.from) : new Date(rangeEnd.getTime() - 29 * 86400000));
36+
let totalDays = Math.ceil((rangeEnd - rangeStart) / 86400000) + 1;
37+
if (totalDays > MAX_DAYS) {
38+
totalDays = MAX_DAYS;
39+
rangeStart = startOfDay(new Date(rangeEnd.getTime() - (MAX_DAYS - 1) * 86400000));
40+
}
41+
if (totalDays < 1) totalDays = 1;
42+
43+
const tasks = await MongoDbCrudOpration(companyId, {
44+
type: SCHEMA_TYPE.TASKS,
45+
data: [
46+
{ projectId: projectObjId, deletedStatusKey: { $in: [0, 2, undefined] }, isParentTask: true },
47+
'_id statusType createdAt updatedAt',
48+
],
49+
}, 'find');
50+
51+
if (!tasks || !tasks.length) {
52+
return res.send({ status: true, statusText: 'No tasks yet.', data: { days: [] } });
53+
}
54+
55+
// Completion date for done tasks (latest Task_Status history; fallback updatedAt).
56+
const doneTasks = tasks.filter((t) => t.statusType === 'close');
57+
const lastStatusChange = new Map();
58+
if (doneTasks.length) {
59+
const idStrings = doneTasks.map((t) => String(t._id));
60+
const idObjects = doneTasks.map((t) => t._id);
61+
const historyRows = await MongoDbCrudOpration(companyId, {
62+
type: SCHEMA_TYPE.HISTORY,
63+
data: [{ Key: 'Task_Status', TaskId: { $in: [...idStrings, ...idObjects] } }, 'TaskId createdAt'],
64+
}, 'find');
65+
(historyRows || []).forEach((row) => {
66+
const k = String(row.TaskId);
67+
const cur = lastStatusChange.get(k);
68+
if (!cur || new Date(row.createdAt) > cur) lastStatusChange.set(k, new Date(row.createdAt));
69+
});
70+
}
71+
72+
const enriched = tasks.map((t) => ({
73+
createdAt: new Date(t.createdAt),
74+
band: BANDS.includes(t.statusType) ? t.statusType : 'open',
75+
completedAt: t.statusType === 'close' ? (lastStatusChange.get(String(t._id)) || new Date(t.updatedAt)) : null,
76+
}));
77+
78+
const days = [];
79+
for (let i = 0; i < totalDays; i++) {
80+
const cursor = endOfDay(new Date(rangeStart.getTime() + i * 86400000));
81+
const counts = { open: 0, inprogress: 0, onhold: 0, close: 0 };
82+
enriched.forEach((t) => {
83+
if (t.createdAt > cursor) return; // not created yet on this day
84+
if (t.completedAt && t.completedAt <= cursor) { counts.close += 1; return; }
85+
// Not yet done as of this day: attribute to current band (a task that
86+
// is now 'close' but wasn't done yet is shown as in-progress).
87+
const band = t.band === 'close' ? 'inprogress' : t.band;
88+
counts[band] += 1;
89+
});
90+
days.push({ date: dayKey(cursor), ...counts });
91+
}
92+
93+
return res.send({ status: true, statusText: 'CFD computed.', data: { days } });
94+
} catch (error) {
95+
logger.error(`ERROR in agile cfd: ${error.message}`);
96+
return res.send({ status: false, statusText: error.message });
97+
}
98+
};

Modules/AgileReports/init.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const routes = require('./routes');
2+
3+
exports.init = (app) => {
4+
routes.init(app);
5+
};

Modules/AgileReports/routes.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const { getSprintBurndown } = require('../Sprints/burndown');
2+
const velocity = require('./velocity');
3+
const cfd = require('./cfd');
4+
5+
exports.init = (app) => {
6+
// Unified agile-reports read API (S4). Burndown reuses the existing Sprints
7+
// handler (now query-param aware); velocity + CFD are computed in this module.
8+
// All read-only; auth/companyId come from the global middleware like other routes.
9+
app.get('/api/v1/agile/burndown', getSprintBurndown);
10+
app.get('/api/v1/agile/velocity', velocity.getVelocity);
11+
app.get('/api/v1/agile/cfd', cfd.getCFD);
12+
};

Modules/AgileReports/velocity.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Sprint velocity (S4-02). Completed story points per sprint for the most
2+
// recent N sprints of a project, plus a rolling-3 average. Computed on-the-fly
3+
// from current task state — committed = total points in the sprint, completed =
4+
// points of tasks currently in a done-category status (statusType 'close').
5+
// Read-only; no new collections or cron.
6+
const { SCHEMA_TYPE } = require('../../Config/schemaType');
7+
const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries');
8+
const mongoose = require('mongoose');
9+
const logger = require('../../Config/loggerConfig');
10+
11+
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
12+
const DONE_TYPE = 'close';
13+
14+
/* GET /api/v1/agile/velocity?projectId=&limit=10 (companyId from header) */
15+
exports.getVelocity = async (req, res) => {
16+
try {
17+
const companyId = req.headers['companyid'] || '';
18+
const projectId = String((req.query && req.query.projectId) || '');
19+
const limit = Math.min(50, Math.max(1, Number(req.query && req.query.limit) || 10));
20+
if (!companyId || !OBJECT_ID_PATTERN.test(projectId)) {
21+
return res.send({ status: false, statusText: 'companyId and a valid projectId are required.' });
22+
}
23+
const projectObjId = new mongoose.Types.ObjectId(projectId);
24+
25+
// All non-deleted sprints for this project.
26+
const sprints = await MongoDbCrudOpration(companyId, {
27+
type: SCHEMA_TYPE.SPRINTS,
28+
data: [
29+
{ projectId: projectObjId, deletedStatusKey: { $ne: 1 } },
30+
'_id name createdAt deletedStatusKey',
31+
],
32+
}, 'find');
33+
34+
if (!sprints || !sprints.length) {
35+
return res.send({ status: true, statusText: 'No sprints yet.', data: { sprints: [] } });
36+
}
37+
38+
// Oldest-first, then keep the most recent `limit`.
39+
const ordered = sprints
40+
.slice()
41+
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
42+
.slice(-limit);
43+
44+
const sprintIds = ordered.map((s) => s._id);
45+
const tasks = await MongoDbCrudOpration(companyId, {
46+
type: SCHEMA_TYPE.TASKS,
47+
data: [
48+
{ sprintId: { $in: sprintIds }, deletedStatusKey: { $in: [0, 2, undefined] }, isParentTask: true },
49+
'_id sprintId points statusType',
50+
],
51+
}, 'find');
52+
53+
const bySprint = new Map();
54+
ordered.forEach((s) => bySprint.set(String(s._id), { committed: 0, completed: 0 }));
55+
(tasks || []).forEach((t) => {
56+
const bucket = bySprint.get(String(t.sprintId));
57+
if (!bucket) return;
58+
const pts = Number(t.points) || 0;
59+
bucket.committed += pts;
60+
if (t.statusType === DONE_TYPE) bucket.completed += pts;
61+
});
62+
63+
const rows = ordered.map((s) => {
64+
const b = bySprint.get(String(s._id)) || { committed: 0, completed: 0 };
65+
return { sprintId: String(s._id), name: s.name || 'Sprint', committed: b.committed, completed: b.completed };
66+
});
67+
68+
// Rolling-3 average of completed points.
69+
const withAvg = rows.map((row, i) => {
70+
const window = rows.slice(Math.max(0, i - 2), i + 1);
71+
const avg = window.reduce((sum, r) => sum + r.completed, 0) / window.length;
72+
return { ...row, rollingAvg: Math.round(avg * 10) / 10 };
73+
});
74+
75+
return res.send({ status: true, statusText: 'Velocity computed.', data: { sprints: withAvg } });
76+
} catch (error) {
77+
logger.error(`ERROR in agile velocity: ${error.message}`);
78+
return res.send({ status: false, statusText: error.message });
79+
}
80+
};

Modules/Export/init.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const routes = require('./routes');
2+
3+
exports.init = (app) => {
4+
routes.init(app);
5+
};

Modules/Export/pdf.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// PDF export (S4-04). A general "render this payload as a branded PDF" endpoint.
2+
// The client sends what it already displays — table rows for timesheets, and
3+
// client-rendered chart images (ApexCharts dataURI) for the agile reports — so
4+
// the PDF always matches the on-screen data and we avoid server-side chart
5+
// rendering and date/unit guesswork. Uses pdfmake with the standard PDF
6+
// Helvetica font (no embedded font files / headless browser — light Docker image).
7+
const path = require('path');
8+
const fs = require('fs');
9+
const logger = require('../../Config/loggerConfig');
10+
11+
// pdfmake is loaded lazily so a missing install never breaks app startup — only
12+
// this endpoint errors (with a clear message) until `npm install` brings it in.
13+
let _printer = null;
14+
function getPrinter() {
15+
if (_printer) return _printer;
16+
const PdfPrinter = require('pdfmake');
17+
_printer = new PdfPrinter({
18+
Helvetica: {
19+
normal: 'Helvetica',
20+
bold: 'Helvetica-Bold',
21+
italics: 'Helvetica-Oblique',
22+
bolditalics: 'Helvetica-BoldOblique',
23+
},
24+
});
25+
return _printer;
26+
}
27+
28+
const BRAND_COLOR = '#2F3990';
29+
30+
function productName() {
31+
try {
32+
const p = path.join(__dirname, '../../brandSettings.json');
33+
if (fs.existsSync(p)) {
34+
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
35+
return data.productName || 'AlianHub';
36+
}
37+
} catch (e) { /* fall through to default */ }
38+
return 'AlianHub';
39+
}
40+
41+
function sanitizeFilename(name) {
42+
return String(name || 'export').replace(/[^a-z0-9-_]+/gi, '_').slice(0, 60) || 'export';
43+
}
44+
45+
const STYLES = {
46+
brand: { fontSize: 16, bold: true, color: BRAND_COLOR },
47+
h1: { fontSize: 18, bold: true, margin: [0, 6, 0, 0] },
48+
sub: { fontSize: 10, color: '#666666', margin: [0, 2, 0, 0] },
49+
th: { bold: true, fontSize: 10, color: '#ffffff' },
50+
meta: { fontSize: 10, color: '#444444', margin: [0, 1, 0, 1] },
51+
total: { bold: true, fontSize: 10 },
52+
};
53+
54+
function headerBlocks(title, subtitle) {
55+
const blocks = [
56+
{ text: productName(), style: 'brand' },
57+
{ text: title || 'Report', style: 'h1' },
58+
];
59+
if (subtitle) blocks.push({ text: subtitle, style: 'sub' });
60+
blocks.push({ canvas: [{ type: 'line', x1: 0, y1: 4, x2: 515, y2: 4, lineWidth: 1, lineColor: '#dddddd' }], margin: [0, 6, 0, 10] });
61+
return blocks;
62+
}
63+
64+
function tableBlock(tableHead, tableRows, totalRow) {
65+
const body = [];
66+
if (Array.isArray(tableHead) && tableHead.length) {
67+
body.push(tableHead.map((h) => ({ text: String(h), style: 'th', fillColor: BRAND_COLOR, margin: [0, 4, 0, 4] })));
68+
}
69+
(tableRows || []).forEach((row) => {
70+
body.push((row || []).map((cell) => ({ text: cell == null ? '' : String(cell), fontSize: 9, margin: [0, 2, 0, 2] })));
71+
});
72+
if (Array.isArray(totalRow) && totalRow.length) {
73+
body.push(totalRow.map((cell) => ({ text: cell == null ? '' : String(cell), style: 'total', fillColor: '#f0f1f7', margin: [0, 4, 0, 4] })));
74+
}
75+
if (!body.length) return { text: 'No data for this selection.', italics: true, color: '#888888' };
76+
const colCount = (tableHead && tableHead.length) || (body[0] && body[0].length) || 1;
77+
return {
78+
table: { headerRows: tableHead && tableHead.length ? 1 : 0, widths: Array(colCount).fill('*'), body },
79+
layout: { hLineColor: () => '#e6e6e6', vLineColor: () => '#e6e6e6' },
80+
margin: [0, 4, 0, 12],
81+
};
82+
}
83+
84+
function streamPdf(res, docDefinition, filename) {
85+
const pdfDoc = getPrinter().createPdfKitDocument(docDefinition);
86+
res.setHeader('Content-Type', 'application/pdf');
87+
res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(filename)}.pdf"`);
88+
pdfDoc.pipe(res);
89+
pdfDoc.end();
90+
}
91+
92+
/* POST /api/v1/export/pdf body: { type, params } (companyId from header)
93+
* params: { title, subtitle?, filename?, meta?: string[], image?|images?: base64 dataURI,
94+
* tableHead?: string[], tableRows?: any[][], totalRow?: any[] } */
95+
exports.exportPdf = (req, res) => {
96+
try {
97+
const companyId = req.headers['companyid'] || '';
98+
const { type, params = {} } = req.body || {};
99+
if (!companyId || !type) {
100+
return res.status(400).send({ status: false, statusText: 'companyId and type are required.' });
101+
}
102+
103+
const content = headerBlocks(params.title, params.subtitle);
104+
105+
if (Array.isArray(params.meta) && params.meta.length) {
106+
params.meta.forEach((line) => content.push({ text: String(line), style: 'meta' }));
107+
content.push({ text: ' ', margin: [0, 0, 0, 6] });
108+
}
109+
110+
const images = params.images || (params.image ? [params.image] : []);
111+
images.forEach((img) => {
112+
if (typeof img === 'string' && img.startsWith('data:image')) {
113+
content.push({ image: img, width: 515, margin: [0, 4, 0, 12] });
114+
}
115+
});
116+
117+
if ((Array.isArray(params.tableHead) && params.tableHead.length) || (Array.isArray(params.tableRows) && params.tableRows.length)) {
118+
content.push(tableBlock(params.tableHead, params.tableRows, params.totalRow));
119+
}
120+
121+
const docDefinition = {
122+
pageSize: 'A4',
123+
pageMargins: [40, 40, 40, 50],
124+
defaultStyle: { font: 'Helvetica', fontSize: 10 },
125+
styles: STYLES,
126+
content,
127+
footer: (currentPage, pageCount) => ({
128+
columns: [
129+
{ text: `Generated ${new Date().toISOString().slice(0, 10)}`, fontSize: 8, color: '#999999', margin: [40, 0, 0, 0] },
130+
{ text: `${currentPage} / ${pageCount}`, alignment: 'right', fontSize: 8, color: '#999999', margin: [0, 0, 40, 0] },
131+
],
132+
}),
133+
};
134+
135+
streamPdf(res, docDefinition, params.filename || type);
136+
} catch (error) {
137+
logger.error(`ERROR in export pdf: ${error.message}`);
138+
if (!res.headersSent) res.status(500).send({ status: false, statusText: error.message });
139+
}
140+
};

Modules/Export/routes.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const pdf = require('./pdf');
2+
3+
exports.init = (app) => {
4+
// Client-shareable PDF export (S4-04). Auth/companyId via global middleware.
5+
app.post('/api/v1/export/pdf', pdf.exportPdf);
6+
};

0 commit comments

Comments
 (0)