diff --git a/package.json b/package.json index b1de8a79..65c217f3 100644 --- a/package.json +++ b/package.json @@ -73,9 +73,9 @@ "jquery": "3.7.1", "js-sql-parser": "1.6.0", "jspdf": "github:shridhar-tl/jsPDF", - "jspdf-autotable": "3.8.2", + "jspdf-autotable": "3.8.4", "moment": "2.30.1", - "moment-timezone": "0.5.45", + "moment-timezone": "0.5.46", "papaparse": "5.4.1", "patternomaly": "1.3.2", "primeflex": "3.3.1", @@ -88,7 +88,7 @@ "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "18.3.1", - "react-router-dom": "7.1.0", + "react-router-dom": "7.1.1", "react-scripts": "github:shridhar-tl/react-scripts", "static-eval": "2.1.1", "zustand": "5.0.2" @@ -98,7 +98,7 @@ "cross-env": "^7.0.3", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.1.0", - "gh-pages": "^6.1.1", + "gh-pages": "^6.2.0", "react-app-alias": "^2.2.2", "sass": "^1.83.0", "webpack-bundle-analyzer": "^4.10.2" @@ -136,6 +136,7 @@ "as-needed" ], "no-unused-vars": "off", + "no-undef": "error", "no-unreachable": "error", "eqeqeq": "error", "semi": "error", diff --git a/src/common/FeedbackPromise.js b/src/common/FeedbackPromise.js index 9c6b10c1..9abe431c 100644 --- a/src/common/FeedbackPromise.js +++ b/src/common/FeedbackPromise.js @@ -14,7 +14,10 @@ class FeedbackPromise extends Promise { }; try { - executor(resolve, reject, progress); + const result = executor(resolve, reject, progress); + if (typeof result?.then === 'function') { + result.then(resolve, reject); + } } catch (err) { reject(err); } diff --git a/src/common/utils.js b/src/common/utils.js index c1eafba6..a6bde095 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -260,4 +260,77 @@ export function viewIssueUrl(root, key) { export function stop(e) { e.stopPropagation(); e.preventDefault(); +} + +export function replaceRepeatedWords(names) { + const total = names.length; + if (total === 0) { return []; } + + // Separate first two and last one characters, and middle part + const separated = names.map(name => { + if (name.length <= 3) { + // If the name is too short, no replacement + return { start: name, middle: '', end: '' }; + } + return { + start: name.slice(0, 2), + middle: name.slice(2, -1), + end: name.slice(-1) + }; + }); + + // Split the middle parts into tokens + const middleTokens = separated.map(parts => parts.middle.split(' ')); + + // Find the maximum number of tokens in the middle parts + const maxTokens = middleTokens.reduce((max, tokens) => Math.max(max, tokens.length), 0); + + // Initialize an array to hold token counts for each position + const tokenCounts = Array.from({ length: maxTokens }, () => ({})); + + // Count occurrences of each token in each position + for (let i = 0; i < maxTokens; i++) { + for (let j = 0; j < total; j++) { + const tokens = middleTokens[j]; + if (i < tokens.length) { + const token = tokens[i]; + // Ignore tokens with numbers and those with length <= 3 + if (!/\d/.test(token) && token.length > 3) { + tokenCounts[i][token] = (tokenCounts[i][token] || 0) + 1; + } + } + } + } + + // Determine which token positions should be replaced + const positionsToReplace = new Set(); + for (let i = 0; i < maxTokens; i++) { + for (const [token, count] of Object.entries(tokenCounts[i])) { + if (count / total >= 0.5) { + positionsToReplace.add(i); + break; // Replace if any token in this position meets the criteria + } + } + } + + // Replace the tokens in the middle parts + const processedMiddle = middleTokens.map(tokens => tokens.map((token, idx) => { + if (positionsToReplace.has(idx) && token.length > 3 && !/\d/.test(token)) { + return '...'; + } + return token; + }).join(' ') + ); + + // Reconstruct the full strings ensuring first two and last one characters are intact + const replacedNames = separated.map((parts, idx) => { + const middle = processedMiddle[idx]; + // Handle cases where middle is empty + if (middle === '') { + return parts.start + parts.end; + } + return (parts.start + middle + parts.end).replace(/([.]+\s*){3,}/g, '...').replace(/([.]+\s*){2}/g, '...'); + }); + + return replacedNames; } \ No newline at end of file diff --git a/src/constants/api-urls.js b/src/constants/api-urls.js index df1a8dc4..a390905b 100644 --- a/src/constants/api-urls.js +++ b/src/constants/api-urls.js @@ -7,6 +7,7 @@ export const ApiUrls = { bulkImportIssue: "~/rest/api/2/issue/bulk", getProjectImportMetadata: "~/rest/api/2/issue/createmeta?expand=projects.issuetypes.fields&projectKeys=", getProjectStatuses: "~/rest/api/2/project/{0}/statuses", + getJiraStatuses: "~/rest/api/2/status", getIssueMetadata: "~/rest/api/2/issue/{0}/editmeta", individualIssue: "~/rest/api/2/issue/{0}", getAllIssueTypes: "~/rest/api/2/issuetype", diff --git a/src/controls/TextBox.jsx b/src/controls/TextBox.jsx index 60aa67ae..691035c9 100644 --- a/src/controls/TextBox.jsx +++ b/src/controls/TextBox.jsx @@ -51,7 +51,7 @@ class TextBox extends PureComponent { if (multiline) { return ( ); } diff --git a/src/dialogs/AddWorklog.jsx b/src/dialogs/AddWorklog.jsx index 0ce8bc4b..9ece3637 100644 --- a/src/dialogs/AddWorklog.jsx +++ b/src/dialogs/AddWorklog.jsx @@ -318,7 +318,7 @@ class AddWorklog extends BaseDialog { Ticket no
- this.setValue("ticketNo", val, true)} /> Provide the ticket no on which you had to log your work diff --git a/src/gadgets/Calendar/Calendar.jsx b/src/gadgets/Calendar/Calendar.jsx index 54d57a19..291e4f7c 100644 --- a/src/gadgets/Calendar/Calendar.jsx +++ b/src/gadgets/Calendar/Calendar.jsx @@ -795,6 +795,12 @@ class Calendar extends BaseGadget { this.addEvent({ previousTime: oldDate, edited: entry }); //this.updateAllDayEvent(event); + }).finally(() => { + const icon = e.el.querySelector('i.fa-refresh'); + if (icon) { + icon.classList.replace('fa-refresh', 'fa-ellipsis-v'); + icon.classList.remove('fa-spin'); + } }); } } diff --git a/src/gadgets/WorklogReport/TicketEstimate.jsx b/src/gadgets/WorklogReport/TicketEstimate.jsx new file mode 100644 index 00000000..fdb6ee21 --- /dev/null +++ b/src/gadgets/WorklogReport/TicketEstimate.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import './TicketEstimate.scss'; + +function TicketEstimate({ est, rem, logged, variance }) { + const estTitle = `Original Estimate: ${est || 0}\nRemaining: ${rem || 0}\nTotal Logged: ${logged}\nEstimate Variance: ${variance}`; + + return ( +
+ + Est: {est} + + + Rem: {rem} + + + Log: {logged} + + + Var: {variance} + +
+ ); +} + +export default TicketEstimate; diff --git a/src/gadgets/WorklogReport/TicketEstimate.scss b/src/gadgets/WorklogReport/TicketEstimate.scss new file mode 100644 index 00000000..693206b7 --- /dev/null +++ b/src/gadgets/WorklogReport/TicketEstimate.scss @@ -0,0 +1,50 @@ +div.ticket-estimate { + display: flex; + align-items: center; + max-width: 500px; + height: 22px; + overflow: hidden; + + .ticket-item { + display: flex; + align-items: center; + clip-path: polygon(10% 0%, 90% 0%, 77% 50%, 90% 100%, 10% 100%, 0% 50%); + padding: 0 25px 2px 8px; + margin: 0; + font-size: 12px; + color: #fff; + white-space: nowrap; + height: 21px; + transition: transform 0.2s; + cursor: pointer; + + .fas { + margin-right: 4px; + font-size: 10px; + } + + &:hover { + transform: scale(1.08); + } + + &.est { + background: linear-gradient(135deg, #d1e7dd, #98e2d6); + color: #0f5132; + } + + &.rem { + background: linear-gradient(135deg, #cff4fc, #89dffd); + color: #055160; + } + + &.log { + background: linear-gradient(135deg, #fff3cd, #ffe066); + color: #664d03; + } + + &.var { + background: linear-gradient(135deg, #f8d7da, #f8d7da); + color: #842029; + } + } +} \ No newline at end of file diff --git a/src/gadgets/WorklogReport/userdaywise/Common.scss b/src/gadgets/WorklogReport/userdaywise/Common.scss index c4cad054..d536bc58 100644 --- a/src/gadgets/WorklogReport/userdaywise/Common.scss +++ b/src/gadgets/WorklogReport/userdaywise/Common.scss @@ -49,8 +49,9 @@ div.scroll-table-container .scroll-table { td { div.wl-ticket-detail { - width: 360px; - height: 25px; + width: 100%; + max-width: 500px; + height: 22px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/gadgets/WorklogReport/userdaywise/shared.jsx b/src/gadgets/WorklogReport/userdaywise/shared.jsx index 7a836929..c64f6cd7 100644 --- a/src/gadgets/WorklogReport/userdaywise/shared.jsx +++ b/src/gadgets/WorklogReport/userdaywise/shared.jsx @@ -1,5 +1,6 @@ import { Image, Link } from '../../../controls'; import { connect } from "../datastore"; +import TicketEstimate from '../TicketEstimate'; export const WeeksList = connect(function ({ weeks }) { return weeks.map((day, i) => {day.display}); @@ -59,8 +60,7 @@ export function IssueInfo({ issue: t, showParentSummary, hideEstimate, convertSe {t.ticketNo} - {t.summary}
- {!hideEstimate && !!(oe || re) && - (est: {oe || 0} / rem: {re || 0} / log: {logged} / var: {variance})} + {!hideEstimate && !!(oe || re) && } ); } diff --git a/src/scss/_custom.scss b/src/scss/_custom.scss index acd2425b..f7949bd4 100644 --- a/src/scss/_custom.scss +++ b/src/scss/_custom.scss @@ -42,10 +42,6 @@ body { /* #region Width related styles */ -.w-p-100 { - width: 100%; -} - .w-80 { width: 80px; } @@ -53,7 +49,7 @@ body { /* #endregion */ /* #region Padding related styles */ -.no-pad { +.no-padding { padding: 0 !important; } diff --git a/src/scss/prime.scss b/src/scss/prime.scss index b51d0e48..b46655cc 100644 --- a/src/scss/prime.scss +++ b/src/scss/prime.scss @@ -164,7 +164,6 @@ a.p-menuitem-link { } .p-tabview-nav-content { - overflow-y: auto; overflow-x: auto; height: 34px; @@ -192,7 +191,8 @@ a.p-menuitem-link { ul.p-dropdown-items, ul.p-autocomplete-items, -ul.p-multiselect-items { +ul.p-multiselect-items, +ul.p-contextmenu-root-list { padding: 0; } diff --git a/src/services/jira-service.js b/src/services/jira-service.js index 74b37ad0..21a3ce24 100644 --- a/src/services/jira-service.js +++ b/src/services/jira-service.js @@ -268,7 +268,7 @@ export default class JiraService { const result = await Promise.all(projects.map(async p => { const cacheKey = `projectStatuses_${p}`; - let project = this.$jaCache.session.get(); + let project = this.$jaCache.session.get(cacheKey); if (!project) { project = await this.$ajax.get(ApiUrls.getProjectStatuses, p); @@ -281,6 +281,18 @@ export default class JiraService { return onlyOne ? result[0] : result; } + async getJiraStatuses() { + const cacheKey = `jiraStatuses`; + let statuses = this.$jaCache.session.get(cacheKey); + + if (!statuses) { + statuses = await this.$ajax.get(ApiUrls.getJiraStatuses); + this.$jaCache.session.set(cacheKey, statuses); + } + + return statuses; + } + async getIssueMetadata(issuekey) { let value = await this.$jaCache.session.getPromise(`issueMetadata_${issuekey}`); if (value) { @@ -431,7 +443,7 @@ export default class JiraService { return result?.value; } catch (err) { if (!err.error?.errorMessages?.[0]?.includes("does not exist")) { - console.error(`Failed to fetch property ${propertyKey} for sprint ${sprintId}:`, error); + console.error(`Failed to fetch property ${propertyKey} for sprint ${sprintId}:`, err); } return null; } diff --git a/src/services/sprint-service.js b/src/services/sprint-service.js index 359cd76d..c1cb2e32 100644 --- a/src/services/sprint-service.js +++ b/src/services/sprint-service.js @@ -10,7 +10,7 @@ export default class SprintService { } computeAverageSprintVelocity = (boardId, noOfSprintsForVelocity = 6, storyPointFieldName, - sprintFieldId, noOfSprintsToPull = noOfSprintsForVelocity * 2, workingDays) => new FeedbackPromise(async (resolve, _, progress) => { + sprintFieldId, noOfSprintsToPull = noOfSprintsForVelocity * 2, workingDays, statusMap) => new FeedbackPromise(async (resolve, _, progress) => { const allClosedSprintLists = await this.$jira.getRapidSprintList([boardId], { state: 'closed' }); progress({ completed: 2 }); const closedSprintLists = allClosedSprintLists.slice(0, noOfSprintsToPull).sortBy(({ completeDate }) => completeDate.getTime()); @@ -20,6 +20,10 @@ export default class SprintService { return resolve({ closedSprintLists, averageCommitted: 0, averageCompleted: 0 }); } + const boardConfig = await this.$jira.getBoardConfig(boardId); + const [boardColumnsOrder, statusBoardColMap] = getStatusBoardColMap(statusMap, boardConfig); + progress({ completed: 4 }); + const sprintIds = closedSprintLists.map(({ id }) => id); const sprintWiseIssues = await this.$jira.getSprintIssues(sprintIds, { @@ -28,124 +32,14 @@ export default class SprintService { includeRemoved: true, boardId }).progress(done => progress({ completed: done / 2 })); + const issueLogs = await getIssueLogsForSprints(closedSprintLists, this.$jira, sprintWiseIssues, progress, sprintFieldId, storyPointFieldName); for (let index = 0; index < closedSprintLists.length; index++) { const sprint = closedSprintLists[index]; - const startDate = moment(sprint.startDate); - const completeDate = moment(sprint.completeDate); sprint.issues = sprintWiseIssues[sprint.id]; - sprint.committedStoryPoints = 0; - sprint.completedStoryPoints = 0; - sprint.sayDoRatio = 0; - const cycleTimes = []; - - sprint.issues.forEach(issue => { // eslint-disable-line complexity - const { resolutiondate, [storyPointFieldName]: storyPoint, created: issueCreated } = issue.fields; - issue.fields.storyPoints = storyPoint; - let $resolutiondate = resolutiondate && moment(resolutiondate); - - if ($resolutiondate && $resolutiondate.isBefore(startDate)) { - issue.completedOutside = true; - return; - } - - const allLogs = issueLogs[issue.id]; - const modifiedWithinSprint = allLogs?.filter(log => moment(log.created).isBetween(startDate, completeDate)); - - const sprintFields = modifiedWithinSprint?.filter(log => log.fieldId === sprintFieldId); - const firstSprintLog = sprintFields?.[0]; - //const lastSprintLog = sprintFields?.[0]; - - //issue.removedFromSprint = lastSprintLog && !lastSprintLog.to.split(',').some(sid => parseInt(sid) === sprint.id); - issue.addedToSprint = startDate.isBefore(issueCreated) - || (firstSprintLog && !firstSprintLog.from.split(',').some(sid => parseInt(sid) === sprint.id)); - - if (!('initialStoryPoints' in issue)) { - issue.initialStoryPoints = parseFloat(storyPoint) || 0; - - if (allLogs?.length) { - const spLog = getFirstModifiedLog(modifiedWithinSprint, storyPointFieldName); - if (spLog) { - issue.initialStoryPoints = parseFloat(spLog.fromString) || 0; - } else { - const spModifiedAfterSprint = allLogs.filter(log => log.fieldId === storyPointFieldName && moment(log.created).isAfter(completeDate))[0]; - if (spModifiedAfterSprint) { - issue.initialStoryPoints = parseFloat(spModifiedAfterSprint.fromString) || 0; - } - } - } - } - - if (modifiedWithinSprint?.length) { - if (!$resolutiondate || $resolutiondate.isAfter(completeDate)) { // If the issue is reopened after Done, then there would be no resolution date - const listOfStatusChanges = modifiedWithinSprint.filter(log => log.fieldId === 'status'); - const lastStatus = listOfStatusChanges.length > 0 && listOfStatusChanges[listOfStatusChanges.length - 1]; - const lastStatusText = (lastStatus && lastStatus.toString?.toLowerCase()) || ''; - - if (lastStatusText === 'done' || lastStatusText.includes('closed')) { - $resolutiondate = moment(lastStatus.created); // use the date completed within sprint as resolution date - console.log('Discrepancy found. Issue status toggled within sprint:', sprint.name, 'issue:', issue.key); - } else if ($resolutiondate) { - $resolutiondate = completeDate; // use the sprint completed date as resolution date if the issue is reopened within sprint - console.log('Discrepancy found. Issue reopened after Done:', sprint.name, 'issue:', issue.key); - } - } - } - - if ($resolutiondate && !issue.removedFromSprint) { - const hasResolvedWithinSprint = $resolutiondate.isBetween(startDate, completeDate); - - if (hasResolvedWithinSprint && allLogs?.length) { - const statusLog = getFirstModifiedLog(allLogs, 'status', 'To Do'); - const firstClosed = getFirstModifiedLog(allLogs, 'status', undefined, ['done', 'closed']); - if (statusLog && (!firstClosed || startDate.isBefore(firstClosed.created))) { // If ticket is once closed before sprint start, then is should not be considered for cycle time - const dateToUse = completeDate.isBefore($resolutiondate) ? completeDate : $resolutiondate; - const ct = getDaysDiffForDateRange(statusLog.created, dateToUse, workingDays); - if (ct > 0) { - issue.cycleTime = ct; - cycleTimes.push(issue.cycleTime); - } - } - } - - if (hasResolvedWithinSprint) { - sprint.completedStoryPoints += (storyPoint || 0); - issue.completed = true; - } else if (storyPoint) { - console.log('Discrepancy found. Issue resolution date is in appropriate:', sprint.name, 'issue:', issue.key); - } - } - - if (!issue.addedToSprint && issue.initialStoryPoints) { - sprint.committedStoryPoints += issue.initialStoryPoints; - } - - if (issue.initialStoryPoints === storyPoint) { - delete issue.initialStoryPoints; - } - }); - - sprint.averageCycleTime = cycleTimes.avg(); - sprint.cycleTimesIssuesCount = cycleTimes.length; - if (sprint.completedStoryPoints) { - if (sprint.committedStoryPoints > sprint.completedStoryPoints) { - sprint.sayDoRatio = parseFloat((sprint.completedStoryPoints * 100 / sprint.committedStoryPoints).toFixed(2)); - } else { - sprint.sayDoRatio = 100; - } - } - - if (index) { - const sprintToCalculate = closedSprintLists.slice(index <= noOfSprintsForVelocity ? 0 : index - noOfSprintsForVelocity); - sprint.velocity = Math.round(sprintToCalculate.avg(s => s.completedStoryPoints)); - const prevSprint = closedSprintLists[index - 1]; - const prevVelocity = index === 1 ? prevSprint?.completedStoryPoints : prevSprint?.velocity; - if (prevVelocity) { - sprint.velocityGrowth = ((sprint.velocity - prevVelocity) * 100) / prevVelocity; - } - } + processSprintData(sprint, issueLogs, { index, noOfSprintsForVelocity, storyPointFieldName, sprintFieldId, closedSprintLists, workingDays, statusBoardColMap }); } const sprintsToConsider = closedSprintLists.slice(-noOfSprintsForVelocity); @@ -162,10 +56,203 @@ export default class SprintService { //const median = Math.round(averageCompleted + (diff / 2)); const logUnavailable = sprintsToConsider.every(s => s.logUnavailable); - resolve({ closedSprintLists, averageCommitted, averageCompleted, velocity: boardVelocity, velocityGrowth, sayDoRatio, averageCycleTime, logUnavailable }); + resolve({ boardColumnsOrder, closedSprintLists, averageCommitted, averageCompleted, velocity: boardVelocity, velocityGrowth, sayDoRatio, averageCycleTime, logUnavailable }); }); } +function processSprintData(sprint, issueLogs, { index, noOfSprintsForVelocity, storyPointFieldName, sprintFieldId, closedSprintLists, workingDays, statusBoardColMap }) { + const startDate = moment(sprint.startDate); + const completeDate = moment(sprint.completeDate); + + sprint.committedStoryPoints = 0; + sprint.completedStoryPoints = 0; + sprint.sayDoRatio = 0; + const cycleTimes = []; + + sprint.statusWiseTimeSpent = sprint.issues.reduce(([statusWiseLogs, statusWiseIssueCount], issue, index, issuesList) => { + const timeSpentInfo = processSprintIssues(sprint, issue, issueLogs[issue.id], cycleTimes, startDate, completeDate, storyPointFieldName, sprintFieldId, workingDays); + if (timeSpentInfo) { + const countIncremented = {}; + Object.keys(timeSpentInfo).forEach(status => { + if (statusBoardColMap) { + status = statusBoardColMap[status]; + } + + if (!status) { return; } + + statusWiseLogs[status] = (statusWiseLogs[status] || 0) + timeSpentInfo[status]; + + if (!countIncremented[status]) { // While multiple status falls under same column, don't increment count for same issue + countIncremented[status] = true; + statusWiseIssueCount[status] = (statusWiseIssueCount[status] || 0) + 1; + } + }); + } + + if (index === issuesList.length - 1) { // If it is last issue, then return the average status wise time spent log + return Object.keys(statusWiseLogs).reduce((avgStatusWiseLog, status) => { + const { [status]: spent } = statusWiseLogs; + const { [status]: issueCount } = statusWiseIssueCount; + + avgStatusWiseLog[status] = spent / issueCount; + + return avgStatusWiseLog; + }, {}); + } else { + return [statusWiseLogs, statusWiseIssueCount]; + } + }, [{}, {}]); + + sprint.averageCycleTime = cycleTimes.avg(); + sprint.cycleTimesIssuesCount = cycleTimes.length; + if (sprint.completedStoryPoints) { + if (sprint.committedStoryPoints > sprint.completedStoryPoints) { + sprint.sayDoRatio = parseFloat((sprint.completedStoryPoints * 100 / sprint.committedStoryPoints).toFixed(2)); + } else { + sprint.sayDoRatio = 100; + } + } + + if (index) { + const sprintToCalculate = closedSprintLists.slice(index <= noOfSprintsForVelocity ? 0 : index - noOfSprintsForVelocity); + sprint.velocity = Math.round(sprintToCalculate.avg(s => s.completedStoryPoints)); + const prevSprint = closedSprintLists[index - 1]; + const prevVelocity = index === 1 ? prevSprint?.completedStoryPoints : prevSprint?.velocity; + if (prevVelocity) { + sprint.velocityGrowth = ((sprint.velocity - prevVelocity) * 100) / prevVelocity; + } + } +} + +// eslint-disable-next-line complexity +function processSprintIssues(sprint, issue, allLogs, cycleTimes, startDate, completeDate, storyPointFieldName, sprintFieldId, workingDays) { + const { resolutiondate, [storyPointFieldName]: storyPoint, created: issueCreated } = issue.fields; + issue.fields.storyPoints = storyPoint; + let $resolutiondate = resolutiondate && moment(resolutiondate); + + if ($resolutiondate && $resolutiondate.isBefore(startDate)) { + issue.completedOutside = true; + return; + } + const startDateForComparison = startDate.clone().add(3, "seconds"); // This is to avoid any logs automatically added due to moving issue to sprint + let modifiedWithinSprint = allLogs?.filter(log => moment(log.created).isBetween(startDateForComparison, completeDate, "milliseconds")); + + const sprintFields = modifiedWithinSprint?.filter(log => log.fieldId === sprintFieldId); + const firstSprintLog = sprintFields?.[0]; + + //issue.removedFromSprint = // This would be already set from JiraService + const isIssueCreatedAfterSprintStart = startDate.isBefore(issueCreated); + issue.addedToSprint = isIssueCreatedAfterSprintStart + || (firstSprintLog && !firstSprintLog.from.split(',').some(sid => parseInt(sid) === sprint.id)); + + if (issue.addedToSprint) { + issue.addedToSprintDate = moment(isIssueCreatedAfterSprintStart && !firstSprintLog?.created ? issueCreated : firstSprintLog.created).add(2, "seconds").toDate(); + // start date should be considered from the time the issue is added to sprint for calculation to work accurately + startDate = moment(issue.addedToSprintDate); + modifiedWithinSprint = allLogs?.filter(log => moment(log.created).isBetween(startDate, completeDate, "milliseconds")); + } + + if (!('initialStoryPoints' in issue)) { + issue.initialStoryPoints = parseFloat(storyPoint) || 0; + + if (allLogs?.length) { + const spLog = getFirstModifiedLog(modifiedWithinSprint, storyPointFieldName); + if (spLog) { + issue.initialStoryPoints = parseFloat(spLog.fromString) || 0; + } else { + const spModifiedAfterSprint = allLogs.filter(log => log.fieldId === storyPointFieldName && moment(log.created).isAfter(completeDate))[0]; + if (spModifiedAfterSprint) { + issue.initialStoryPoints = parseFloat(spModifiedAfterSprint.fromString) || 0; + } + } + } + } + + if (modifiedWithinSprint?.length) { + if (!$resolutiondate || $resolutiondate.isAfter(completeDate)) { // If the issue is reopened after Done, then there would be no resolution date + const listOfStatusChanges = modifiedWithinSprint.filter(log => log.fieldId === 'status'); + const lastStatus = listOfStatusChanges.length > 0 && listOfStatusChanges[listOfStatusChanges.length - 1]; + const lastStatusText = (lastStatus && lastStatus.toString?.toLowerCase()) || ''; + + if (lastStatusText === 'done' || lastStatusText.includes('closed')) { + $resolutiondate = moment(lastStatus.created); // use the date completed within sprint as resolution date + console.log('Discrepancy found. Issue status toggled within sprint:', sprint.name, 'issue:', issue.key); + } else if ($resolutiondate) { + $resolutiondate = completeDate; // use the sprint completed date as resolution date if the issue is reopened within sprint + console.log('Discrepancy found. Issue reopened after Done:', sprint.name, 'issue:', issue.key); + } + } + } + + if ($resolutiondate && !issue.removedFromSprint) { + const hasResolvedWithinSprint = $resolutiondate.isBetween(startDate, completeDate); + + if (hasResolvedWithinSprint && allLogs?.length) { + const statusLog = getFirstModifiedLog(allLogs, 'status', 'To Do'); + const firstClosed = getFirstModifiedLog(allLogs, 'status', undefined, ['done', 'closed']); + if (statusLog && (!firstClosed || startDate.isBefore(firstClosed.created))) { // If ticket is once closed before sprint start, then is should not be considered for cycle time + const dateToUse = completeDate.isBefore($resolutiondate) ? completeDate : $resolutiondate; + const ct = getDaysDiffForDateRange(statusLog.created, dateToUse, workingDays); + if (ct > 0) { + issue.cycleTime = ct; + cycleTimes.push(issue.cycleTime); + } + } + } + + if (hasResolvedWithinSprint) { + sprint.completedStoryPoints += (storyPoint || 0); + issue.completed = true; + } else if (storyPoint) { + console.log('Discrepancy found. Issue resolution date is in appropriate:', sprint.name, 'issue:', issue.key); + } + } + + if (!issue.addedToSprint && issue.initialStoryPoints) { + sprint.committedStoryPoints += issue.initialStoryPoints; + } + + if (issue.initialStoryPoints === storyPoint) { + delete issue.initialStoryPoints; + } + + return calculateStatusWiseTimeSpent(issue, allLogs, startDate, completeDate, workingDays); +} + +function calculateStatusWiseTimeSpent(issue, allLogs, sprintStartDate, sprintEndDate, workingDays) { + if (!allLogs?.length || issue.removedFromSprint) { return {}; } + + if (issue.addedToSprint && issue.addedToSprintDate) { + sprintStartDate = moment(issue.addedToSprintDate); // If issue added to sprint later, then consider that as start date + } + + // filter and simplify status logs for entire duration + const statusLogs = allLogs.filter(l => l.fieldId === 'status' && moment(l.created).isSameOrBefore(sprintEndDate)) + .map(l => ({ status: l.toString, startDate: moment(l.created) })); + if (!statusLogs.length) { return {}; } + + const indexOfFirstChangeAfterSprintStart = statusLogs.findIndex(l => l.startDate.isSameOrAfter(sprintStartDate)); + if (indexOfFirstChangeAfterSprintStart > 1) { // See if more than one log is available before start of sprint + statusLogs.splice(0, indexOfFirstChangeAfterSprintStart - 1); // Keep only the last log which happened before start of sprint + } else if (indexOfFirstChangeAfterSprintStart === -1) { + statusLogs.splice(0, statusLogs.length - 1); // Keep only the last log which happened before start of sprint + } + + if (statusLogs[0].startDate.isBefore(sprintStartDate)) { // If first log has happened before start of sprint, then change it to exact start of sprint + statusLogs[0].startDate = sprintStartDate; + } + + const statusWiseTimeSpent = statusLogs.reduce((result, log, i) => { + const nextLogTime = statusLogs[i + 1]?.startDate ?? sprintEndDate; + result[log.status] = (result[log.status] || 0) + getDaysDiffForDateRange(log.startDate, nextLogTime, workingDays); + return result; + }, {}); + + issue.statusWiseTimeSpent = statusWiseTimeSpent; + + return statusWiseTimeSpent; +} + function getFirstModifiedLog(logs, fieldId, fromString, toString) { if (!logs?.length) { return; @@ -233,4 +320,28 @@ function addRemovedIssuesToMissingSprints(sprintWiseIssues, sprintIssueLogs, cur } }); }); +} + +function getStatusBoardColMap(statusMap, boardConfig) { + const statusBoardColMap = {}; + const boardColumns = boardConfig?.columnConfig?.columns; + if (!boardColumns) { + return; + } + + const boardColumnsOrder = {}; + boardColumns.forEach((col, i) => { + const boardColName = col.name; + boardColumnsOrder[boardColName] = i; + + col.statuses.forEach(s => { + const statusText = statusMap[s.id]; + if (statusBoardColMap[statusText]) { // This should not happen ideally + console.error(`Unexpected Error: Status ${statusText} is mapped to ${statusBoardColMap[statusText]} and ${boardColName}`); + } + statusBoardColMap[statusText] = boardColName; + }); + }); + + return [boardColumnsOrder, Object.keys(statusBoardColMap).length ? statusBoardColMap : undefined]; } \ No newline at end of file diff --git a/src/views/reports/custom-groupable/QueryEditor.jsx b/src/views/reports/custom-groupable/QueryEditor.jsx index 5d506305..333b3cbe 100644 --- a/src/views/reports/custom-groupable/QueryEditor.jsx +++ b/src/views/reports/custom-groupable/QueryEditor.jsx @@ -83,7 +83,7 @@ class QueryEditor extends BaseGadget { return super.renderBase(
- +
Filter (JQL): JQL (Jira Query Language) is a powerful tool for filtering and extracting data from Jira. diff --git a/src/views/reports/say-do-ratio/ReportData.jsx b/src/views/reports/say-do-ratio/ReportData.jsx new file mode 100644 index 00000000..0bdc9104 --- /dev/null +++ b/src/views/reports/say-do-ratio/ReportData.jsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { Column, NoDataRow, ScrollableTable, TBody, THead } from 'src/components/ScrollableTable'; +import SayDoRatioChart from './SayDoRatioChart'; +import Indicator from '../../../components/worklog-indicator'; +import SprintInfo from './SprintInfo'; +import SprintStatusWiseTimeSpentChart from './SprintStatusWiseTimeSpentChart'; + +const changeLogErrorMessage = "Unable to fetch change logs and hence data may not be accurate"; + +function ReportData({ reportData, settings }) { + const [selectedSprint, setSprint] = React.useState(); + + return (<> + + + + Board Name + Velocity + Say-Do-Ratio + Cycle Time + {loop(settings.noOfSprints, (i) => { + const sprintTitle = settings.noOfSprints === (i + 1) ? `Last sprint (n-1)` : `Sprint n${i - settings.noOfSprints}`; + return ({sprintTitle}); + })} + + + + {(b) => + {b.name} {b.logUnavailable && } + {b.velocity || '-'} {!!b.velocity && ({parseFloat(b.velocityGrowth?.toFixed(2) || 0)}%)} + + {formatValue(b.sayDoRatio)} + {!!b.sayDoRatio && } + + {b.averageCycleTime ? `${b.averageCycleTime} days` : '-'} + {b.sprintList.map((s, index) => (s ? ( setSprint(s)} key={s.id}> + + {!!s.sayDoRatio && {s.sayDoRatio}%} + {!s.sayDoRatio && -} + {!b.logUnavailable && s.logUnavailable && } + {!!s.sayDoRatio && } + ) : -))} + } + + {!reportData?.length && No data available.} + + + + + + + + + {loop(settings.noOfSprints, (i) => { + const sprintTitle = settings.noOfSprints === (i + 1) ? `Last sprint (n-1)` : `Sprint n${i - settings.noOfSprints}`; + return (); + })} + + + {loop(settings.noOfSprints, (i) => ( + + + + ))} + + + + {reportData.map((b) => + + + + + {b.sprintList.map((s, i) => ( + + + + ))} + )} + +
Board NameVelocitySay-Do-RatioCycle Time{sprintTitle}
Say-Do-RatioVelocityCycle Time
{b.name}{b.velocity} {!!b.velocity && ({parseFloat(b.velocityGrowth?.toFixed(2) || 0)}%)}{formatValue(b.sayDoRatio)}{formatValue(b.averageCycleTime, ' days')}{formatValue(s?.sayDoRatio)}{formatValue(s?.velocity)}{formatValue(s?.cycleTime)}
+ {selectedSprint &&
+ setSprint(null)} /> +
} +
+ {reportData?.map(b => <> + + + )} +
+ ); +} + +export default ReportData; + +function formatValue(value, suffix = "%", defaultValue = '') { + return value ? `${value}${suffix}` : defaultValue; +} + +function getLogClass(value, isSelected) { + if (!value) { + return isSelected ? 'text-center selected' : 'text-center'; + } + + let className = 'log-good'; + + if (value >= 85) { + className = 'log-good'; + } else if (value >= 70) { + className = 'log-less'; + } else { + className = 'log-high'; + } + + return `log-indi-cntr ${className}${isSelected ? ' selected' : ''}`; +} + +function loop(num, callback) { + const result = []; + + for (let i = 0; i < num; i++) { + result.push(callback(i)); + } + + return result; +} \ No newline at end of file diff --git a/src/views/reports/say-do-ratio/ReportInfo.jsx b/src/views/reports/say-do-ratio/ReportInfo.jsx new file mode 100644 index 00000000..846766f4 --- /dev/null +++ b/src/views/reports/say-do-ratio/ReportInfo.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { isPluginBuild } from 'src/constants/build-info'; + +function HelpText() { + return ( +
+
+

How to Use the Say Do Ratio Report

+
    +
  • + Say Do Ratio Report serves as a valuable tool to summarize achievements within each sprint and to monitor progress sprint over sprint. +
  • +
  • + Begin by selecting your filters and configuration options. Click on the icon located at the top right corner of the report to access report config: +
      +
    • Select Sprint Boards: Choose all sprint boards for which you wish to view the report.
    • +
    • Number of Sprints to Display: Specify the number of completed sprints you would like to include in the report. The minimum allowed is 3.
    • +
    • Number of Sprints for Velocity: Indicate the number of sprints to be used for velocity calculations (min 3, max 9).
    • +
    • Include Non-Working Days in Cycle Time Calculation: Decide if you want to include non-working days based on your General settings.
    • +
    +
  • +
  • + Once all configurations are set, click the "Generate Report" button to produce the report. + Be prepared for potential delays based on the number of boards and sprints selected. +
  • +
+

+ Report Structure: + This report displays a comprehensive table summarizing all boards and sprints, along with individual charts for each board. + This report supports exporting data in Excel, PDF, and CSV formats. + User feedback for suggestions and bug reports is appreciated to enhance this report. +

+

+ Table Structure: + The report table contains the following columns: +

    +
  • Board Name: Name of the selected board.
  • +
  • Velocity: Expected velocity for the upcoming sprint based on completed story points.
  • +
  • Say-Do Ratio: Average Say-Do Ratio for the sprints included in the report.
  • +
  • Cycle Time: Average cycle time calculated using specific configurations.
  • +
  • Sprints: Each sprint is represented in individual columns, showing the average Say-Do Ratio for that sprint.
  • +
+

+ +
+

+ Important Note: +

+
    + {!isPluginBuild &&
  • This report requires access to change logs and utilizes a new API introduced by Jira, currently available only to Jira Cloud users. It is still in preview and not yet rolled out to all the Jira Instances.
  • } + {isPluginBuild &&
  • This report requires access to change logs and utilizes a new API introduced by Jira, which is still in preview and not yet rolled out to all the Jira Instances.
  • } +
  • As it uses some of the API's which are still in preview, this report may not be functional for all Jira Instances immediately. Hence if it does not work then wait for few days so that your region gets the update from Jira.
  • +
  • While there are no APIs to retrieve information about stories that were part of a sprint at its initiation, accurate calculation of the + Say-Do Ratio can be challenging if a story is removed during the sprint. + {!isPluginBuild && <> A workaround is implemented by using some of the Jira's internal API's, which is not available while you use oAuth authentication.} + {isPluginBuild && <> A workaround is implemented for upcoming sprints, but results for sprints that have already begun may not be precise.} +
  • + {!isPluginBuild &&
  • Using the report with oAuth have lack of functionalities due to the above mentioned limitations.
  • } +
+
+
+
+ ); +} + +export default HelpText; diff --git a/src/views/reports/say-do-ratio/SayDoRatioChart.jsx b/src/views/reports/say-do-ratio/SayDoRatioChart.jsx index aeef40b5..36533327 100644 --- a/src/views/reports/say-do-ratio/SayDoRatioChart.jsx +++ b/src/views/reports/say-do-ratio/SayDoRatioChart.jsx @@ -1,12 +1,26 @@ import { Chart } from 'primereact/chart'; import React from 'react'; +import { replaceRepeatedWords } from 'src/common/utils'; const documentStyle = getComputedStyle(document.documentElement); const textColor = documentStyle.getPropertyValue('--text-color'); const textColorSecondary = documentStyle.getPropertyValue('--text-color-secondary'); const surfaceBorder = documentStyle.getPropertyValue('--surface-border'); -function getOptions(titleText, subTitle, minY, maxY) { +function getOptions(titleText, subTitle, minY, maxY, xAxisLabels) { + const xAxis = { + ticks: { + color: textColorSecondary + }, + grid: { + color: surfaceBorder + } + }; + + if (xAxisLabels.length > 4) { + xAxis.ticks.callback = (_, index) => xAxisLabels[index]; + } + return { maintainAspectRatio: false, responsive: true, @@ -47,14 +61,7 @@ function getOptions(titleText, subTitle, minY, maxY) { intersect: false }, scales: { - x: { - ticks: { - color: textColorSecondary - }, - grid: { - color: surfaceBorder - } - }, + x: xAxis, y: { title: { display: true, @@ -73,8 +80,10 @@ function getOptions(titleText, subTitle, minY, maxY) { position: 'right', title: { display: true, - text: 'Cycle Time (Days)' + text: 'Say Do Ratio (%)' }, + min: 0, + max: 100, ticks: { color: textColorSecondary }, @@ -97,26 +106,18 @@ function getChartData(sprintList, key, label, borderColor, others) { }; } -function getCycleTimeData(sprintList) { - return { - label: 'Cycle Time', - data: sprintList.map(({ averageCycleTime }) => averageCycleTime), - backgroundColor: '#FFD700', - yAxisID: 'y1', - type: 'bar' - }; -} - function SayDoRatioChart({ board }) { const { data, options } = React.useMemo(() => { const { name, sprintList, velocity } = board; const availableSprints = sprintList.filter(Boolean); const labels = availableSprints.map(s => s.name); + const shortenedLabels = replaceRepeatedWords(labels); + const datasets = [ getChartData(availableSprints, 'velocity', 'Velocity', '#4169E1', { borderDash: [5, 5] }), getChartData(availableSprints, 'committedStoryPoints', 'Committed', '#FF6347'), getChartData(availableSprints, 'completedStoryPoints', 'Completed', '#228B22'), - getCycleTimeData(availableSprints) + getChartData(availableSprints, 'sayDoRatio', 'Say Do Ratio', '#c4a6ff', { yAxisID: 'y1', fill: true, backgroundColor: '#c4a6ff6b' }) ]; let minY = 7, maxY = 7; @@ -143,7 +144,7 @@ function SayDoRatioChart({ board }) { return { data: { labels, datasets }, - options: getOptions(name, `Velocity: ${velocity ?? '(Unavailable)'}`, minY, maxY + 2) + options: getOptions(name, `Velocity: ${velocity ?? '(Unavailable)'}`, minY, maxY + 2, shortenedLabels) }; }, [board]); diff --git a/src/views/reports/say-do-ratio/SprintStatusWiseTimeSpentChart.jsx b/src/views/reports/say-do-ratio/SprintStatusWiseTimeSpentChart.jsx new file mode 100644 index 00000000..5033a5b7 --- /dev/null +++ b/src/views/reports/say-do-ratio/SprintStatusWiseTimeSpentChart.jsx @@ -0,0 +1,174 @@ +import { Chart } from 'primereact/chart'; +import React from 'react'; +import { replaceRepeatedWords } from 'src/common/utils'; + +const documentStyle = getComputedStyle(document.documentElement); +const textColor = documentStyle.getPropertyValue('--text-color'); +const textColorSecondary = documentStyle.getPropertyValue('--text-color-secondary'); +const surfaceBorder = documentStyle.getPropertyValue('--surface-border'); +const defaultLineColors = [ + 'rgba(255, 99, 132)', + 'rgba(54, 162, 235)', + 'rgb(105 0 251)', + 'rgba(75, 192, 192)', + 'rgba(123, 12, 55)', + 'rgba(255, 159, 64)', + 'rgba(34, 33, 219)', + 'rgba(25, 100, 229)', +]; + +function getOptions(titleText, subTitle, minY, maxY, xAxisLabels) { + const xAxis = { + ticks: { + color: textColorSecondary + }, + grid: { + color: surfaceBorder + } + }; + + if (xAxisLabels.length > 4) { + xAxis.ticks.callback = (_, index) => xAxisLabels[index]; + } + + return { + maintainAspectRatio: false, + responsive: true, + plugins: { + title: { + display: true, + text: titleText, + align: "center", + font: { size: '16px' }, + padding: 0 + }, + subtitle: { + display: true, + text: subTitle, + padding: 10 + }, + legend: { + position: 'bottom', + labels: { + color: textColor + } + }, + tooltip: { + mode: 'index', + intersect: false, + padding: 12, + boxPadding: 6, + callbacks: { + title: (tooltipItems) => `Sprint: ${tooltipItems[0].label}`, + label: (tooltipItem) => + `${tooltipItem.dataset.label}: ${tooltipItem.formattedValue} days` + }, + bodySpacing: 10 + } + }, + hover: { + mode: 'index', + intersect: false + }, + scales: { + x: xAxis, + y: { + title: { + display: true, + text: 'Time Spent (days)' + }, + min: minY, + max: maxY, + ticks: { + color: textColorSecondary + }, + grid: { + color: surfaceBorder + } + }, + y1: { + position: 'right', + title: { + display: true, + text: 'Cycle Time (Days)' + }, + ticks: { + color: textColorSecondary + }, + grid: { + drawOnChartArea: false + } + } + } + }; +} + +function getChartData(sprintList, key, label, borderColor, others) { + return { + label, + data: sprintList.map(s => s.statusWiseTimeSpent[key] || 0), + fill: false, + borderColor, + tension: 0.4, + ...others + }; +} + +function getCycleTimeData(sprintList) { + return { + label: 'Cycle Time', + data: sprintList.map(({ averageCycleTime }) => averageCycleTime), + backgroundColor: '#FFD700', + yAxisID: 'y1', + type: 'bar' + }; +} + +function SprintStatusWiseTimeSpentChart({ board }) { + const { data, options } = React.useMemo(() => { + const { name, sprintList, boardColumnsOrder } = board; + const availableSprints = sprintList.filter(Boolean); + const labels = availableSprints.map(s => s.name); + const shortenedLabels = replaceRepeatedWords(labels); + const statusList = availableSprints.flatMap(s => Object.keys(s.statusWiseTimeSpent)).distinct().sortBy(s => boardColumnsOrder[s] ?? 10); + const datasets = statusList.map((s, i) => getChartData(availableSprints, s, s, defaultLineColors[i])); + datasets.push(getCycleTimeData(availableSprints)); + + let minY = 2, maxY = 4; + + for (const ds of datasets) { + if (ds.yAxisID !== 'y1') { + for (const val of ds.data) { + if (val < minY) { + minY = val; + } + + if (val > maxY) { + maxY = val; + } + } + } + } + + if (minY <= 1) { + minY = -1; + } else if (minY <= 2) { + minY = 0; + } else { + minY -= 1; + } + + return { + data: { labels, datasets }, + options: getOptions(name, `Status Wise Time Spent`, minY, maxY + 1, shortenedLabels) + }; + }, [board]); + + return ( +
+ +
+ ); +} + +export default SprintStatusWiseTimeSpentChart; \ No newline at end of file diff --git a/src/views/reports/say-do-ratio/helper.js b/src/views/reports/say-do-ratio/helper.js index 69b82f8a..311b6290 100644 --- a/src/views/reports/say-do-ratio/helper.js +++ b/src/views/reports/say-do-ratio/helper.js @@ -10,17 +10,24 @@ export function getSprintWiseSayDoRatio(settings) { await $config.saveSettings(settingsName, { sprintBoards, noOfSprints, velocitySprints, includeNonWorkingDays }); const customFields = await $jira.getCustomFields(); + const statuses = await $jira.getJiraStatuses(); + + const statusMap = statuses.reduce((map, s) => { + map[s.id] = s.untranslatedName || s.name; + return map; + }, {}); + const sprintFieldId = customFields.find(({ name }) => name === 'Sprint')?.id; - progress({ data: [], completed: 5 }); + progress({ data: [], completed: 2 }); const workingDaysToUse = includeNonWorkingDays ? undefined : workingDays; const result = []; for (const { id, name } of sprintBoards) { const { closedSprintLists, ...boardProps } = await $sprint.computeAverageSprintVelocity(id, - velocitySprints, storyPointField, sprintFieldId, noOfSprints + velocitySprints, workingDaysToUse) + velocitySprints, storyPointField, sprintFieldId, noOfSprints + velocitySprints, workingDaysToUse, statusMap) .progress(({ completed }) => { - progress({ completed: 5 + (((result.length + (completed / 100)) * 95) / sprintBoards.length) }); + progress({ completed: 2 + (((result.length + (completed / 100)) * 98) / sprintBoards.length) }); }); const sprintList = closedSprintLists.slice(-noOfSprints); diff --git a/src/views/reports/say-do-ratio/index.jsx b/src/views/reports/say-do-ratio/index.jsx index 316dbf42..89e7b260 100644 --- a/src/views/reports/say-do-ratio/index.jsx +++ b/src/views/reports/say-do-ratio/index.jsx @@ -3,19 +3,14 @@ import { getSprintWiseSayDoRatio, getSettings } from './helper'; import GadgetLayout from '../../../gadgets/Gadget'; import ReportSettings from './settings'; import useToggler from 'react-controls/hooks/useToggler'; -import { Column, NoDataRow, ScrollableTable, TBody, THead } from 'src/components/ScrollableTable'; -import SayDoRatioChart from './SayDoRatioChart'; -import Indicator from '../../../components/worklog-indicator'; import { Button } from '../../../controls'; -import SprintInfo from './SprintInfo'; +import HelpText from './ReportInfo'; +import ReportData from './ReportData'; import './SayDoRatioReport.scss'; -const changeLogErrorMessage = "Unable to fetch change logs and hence data may not be accurate"; - function SayDoRatioReport() { const [isLoading, setLoader] = React.useState(false); const [progress, setProgress] = React.useState(); - const [selectedSprint, setSprint] = React.useState(); const [editMode, toggleEdit] = useToggler(true); const [settings, updateSettings] = React.useState(getSettings()); const [reportData, setReportData] = React.useState([]); @@ -26,7 +21,6 @@ function SayDoRatioReport() { const loadReportData = React.useCallback(async () => { try { setProgress(0); - setSprint(null); setReportData([]); setLoader(true); const reportData = await getSprintWiseSayDoRatio($this.current.settings).progress(({ completed, data }) => { @@ -60,114 +54,11 @@ function SayDoRatioReport() { isGadget={false} isLoading={isLoading} loadingProgress={progress} onRefresh={loadReportData} customActions={customActions} > - - - - Board Name - Velocity - Say-Do-Ratio - Cycle Time - {loop(settings.noOfSprints, (i) => { - const sprintTitle = settings.noOfSprints === (i + 1) ? `Last sprint (n-1)` : `Sprint n${i - settings.noOfSprints}`; - return ({sprintTitle}); - })} - - - - {(b) => - {b.name} {b.logUnavailable && } - {b.velocity || '-'} {!!b.velocity && ({parseFloat(b.velocityGrowth?.toFixed(2) || 0)}%)} - - {formatValue(b.sayDoRatio)} - {!!b.sayDoRatio && } - - {b.averageCycleTime ? `${b.averageCycleTime} days` : '-'} - {b.sprintList.map((s, index) => (s ? ( setSprint(s)} key={s.id}> - - {!!s.sayDoRatio && {s.sayDoRatio}%} - {!s.sayDoRatio && -} - {!b.logUnavailable && s.logUnavailable && } - {!!s.sayDoRatio && } - ) : -))} - } - - {!reportData?.length && No data available.} - - - - - - - - - {loop(settings.noOfSprints, (i) => { - const sprintTitle = settings.noOfSprints === (i + 1) ? `Last sprint (n-1)` : `Sprint n${i - settings.noOfSprints}`; - return (); - })} - - - {loop(settings.noOfSprints, (i) => ( - - - - ))} - - - - {reportData.map((b) => - - - - - {b.sprintList.map((s, i) => ( - - - - ))} - )} - -
Board NameVelocitySay-Do-RatioCycle Time{sprintTitle}
Say-Do-RatioVelocityCycle Time
{b.name}{b.velocity} {!!b.velocity && ({parseFloat(b.velocityGrowth?.toFixed(2) || 0)}%)}{formatValue(b.sayDoRatio)}{formatValue(b.averageCycleTime, ' days')}{formatValue(s?.sayDoRatio)}{formatValue(s?.velocity)}{formatValue(s?.cycleTime)}
- {selectedSprint &&
- setSprint(null)} /> -
} -
- {reportData?.map(b => )} -
+ {!reportData?.length && } + {reportData?.length > 0 && }
); } export default SayDoRatioReport; - -function formatValue(value, suffix = "%", defaultValue = '') { - return value ? `${value}${suffix}` : defaultValue; -} - -function getLogClass(value, isSelected) { - if (!value) { - return isSelected ? 'text-center selected' : 'text-center'; - } - - let className = 'log-good'; - - if (value >= 85) { - className = 'log-good'; - } else if (value >= 70) { - className = 'log-less'; - } else { - className = 'log-high'; - } - - return `log-indi-cntr ${className}${isSelected ? ' selected' : ''}`; -} - -function loop(num, callback) { - const result = []; - - for (let i = 0; i < num; i++) { - result.push(callback(i)); - } - - return result; -} \ No newline at end of file diff --git a/src/views/reports/sprint-report/SprintWiseWorklog.jsx b/src/views/reports/sprint-report/SprintWiseWorklog.jsx index ab8cd9e1..9b83d54a 100644 --- a/src/views/reports/sprint-report/SprintWiseWorklog.jsx +++ b/src/views/reports/sprint-report/SprintWiseWorklog.jsx @@ -330,7 +330,7 @@ class SprintWiseWorklog extends PureComponent { - {(sprint, i) => } diff --git a/src/views/settings/general/MeetingsTab.jsx b/src/views/settings/general/MeetingsTab.jsx index 9de6eabc..1ded1cf0 100644 --- a/src/views/settings/general/MeetingsTab.jsx +++ b/src/views/settings/general/MeetingsTab.jsx @@ -66,12 +66,15 @@ class MeetingsTab extends TabControlBase { enableOIntegration = (val) => this.saveSetting(val, "outlookIntegration"); googleSignIn = () => { - this.$calendar.authenticate(true).then((result) => { + this.$calendar.authenticate(true).then(() => { this.saveSetting(true, "hasGoogleCredentials"); this.$session.CurrentUser.hasGoogleCredentials = true; this.$analytics.trackEvent("Signedin to Google Calendar"); this.$message.success("Successfully integrated with google account."); - }, (err) => { this.$message.warning("Unable to integrate with Google Calendar!"); }); + }, (err) => { + this.$message.warning("Unable to integrate with Google Calendar!"); + console.error("Unable to integrate with Google Calendar!", err); + }); }; outlookSignIn = () => {