Last active
August 17, 2023 08:26
-
-
Save dantodev/8179ce45abf36a3fbc5bcc815653deeb to your computer and use it in GitHub Desktop.
Tampermonkey Script - [Jira] Add Story Points swimlane
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name [Jira] Show SP on Swimlane | |
// @description Show open, done and total story points on jira swimlanes | |
// @author Daniel Kahl | |
// @version 1.0 | |
// @match https://kanasoftware.jira.com/jira/software/c/projects/VOC/boards/* | |
// @namespace http://tampermonkey.net/ | |
// @downloadURL https://gist.githubusercontent.com/dantodev/8179ce45abf36a3fbc5bcc815653deeb/raw/tm-jira-swimlane-header.js | |
// @updateURL https://gist.githubusercontent.com/dantodev/8179ce45abf36a3fbc5bcc815653deeb/raw/tm-jira-swimlane-header.js | |
// @grant GM_log | |
// ==/UserScript== | |
(function () { | |
"use strict"; | |
const columnColors = [ | |
{ pattern: /review/i, default: "#f1c40f" }, | |
{ pattern: /testing/i, default: "#9b59b6" }, | |
{ pattern: /progress/i, default: "#2980b9" }, | |
{ pattern: /open|to do/i, default: "#ecf0f1", header: "#bdc3c7" }, | |
{ pattern: /done/i, default: "#2ecc71" } | |
]; | |
let firstRun = true; | |
function start() { | |
log("starting user script to show more details on Jira swimlanes"); | |
injectCustomStyles(); | |
setInterval(intervalCallback, 2500); | |
} | |
function intervalCallback() { | |
const board = document.querySelector("#ghx-pool"); | |
if (!board) return; | |
const data = scrapeBoardData(board); | |
if (firstRun) { | |
log("detected board data:", data); | |
firstRun = false; | |
} | |
if (unsafeWindow.pauseUpdating) return; | |
renderSwimlaneHeader(data); | |
renderBoardHeader(data); | |
} | |
function scrapeBoardData(board) { | |
const headerColumns = scrapeHeaderColumns(document); | |
const swimlanes = scrapeSwimlane(board); | |
const columns = []; | |
const issues = []; | |
swimlanes.forEach((swimlane) => { | |
swimlane.totalSP = 0; | |
swimlane.totalIssues = 0; | |
swimlane.columns.forEach((column) => { | |
const headerColumn = headerColumns.find((hc) => hc.id === column.id); | |
column.name = headerColumn?.name; | |
column.swimlaneId = swimlane.id; | |
column.totalSP = 0; | |
column.totalIssues = 0; | |
column.issues.forEach((issue) => { | |
issue.swimlaneId = swimlane.id; | |
issue.columnId = column.id; | |
column.totalSP += issue.estimate; | |
swimlane.totalSP += issue.estimate; | |
column.totalIssues++; | |
swimlane.totalIssues++; | |
issues.push(issue); | |
}); | |
}); | |
}); | |
headerColumns.forEach((column) => { | |
const columnIssues = issues.filter((issue) => issue.columnId === column.id); | |
const totalSP = columnIssues.reduce((total, issue) => total + issue.estimate, 0); | |
columns.push({ ...column, issues: columnIssues, totalSP }); | |
}); | |
return { swimlanes, columns, issues }; | |
} | |
const scrapeHeaderColumns = pipe( | |
all(".ghx-column-headers > li"), | |
map((columnNode) => ({ | |
columnNode, | |
id: pipe(extractAttribute("data-id"))(columnNode), | |
name: pipe(find(".ghx-column h2"), extractText())(columnNode) | |
})) | |
); | |
const scrapeSwimlane = pipe( | |
all(".ghx-swimlane"), | |
map((swimlaneNode) => ({ | |
swimlaneNode, | |
name: pipe(find(".ghx-swimlane-header .ghx-heading span[role=button]"), extractText())(swimlaneNode), | |
columns: scrapeSwimlaneColumn(swimlaneNode) | |
})) | |
); | |
const scrapeSwimlaneColumn = pipe( | |
all(".ghx-column"), | |
map((columnNode) => ({ | |
columnNode, | |
id: columnNode.getAttribute("data-column-id"), | |
issues: scrapeIssue(columnNode) | |
})) | |
); | |
const scrapeIssue = pipe( | |
all(".ghx-issue"), | |
map((issueNode) => ({ | |
node: issueNode, | |
key: pipe(extractAttribute("data-issue-key"))(issueNode), | |
summary: pipe(find(".ghx-summary"), extractText())(issueNode), | |
estimate: pipe(find(".ghx-estimate"), extractText(), toNumber())(issueNode), | |
type: pipe(find(".ghx-field-icon:first-child"), extractAttribute("data-tooltip"))(issueNode), | |
blocked: pipe(all(".ghx-extra-field[data-tooltip^='Reason Blocked']"), toBool())(issueNode) | |
})) | |
); | |
function renderSwimlaneHeader({ swimlanes }) { | |
swimlanes.forEach(({ name, totalSP, totalIssues, swimlaneNode, columns }) => { | |
let totalOpen = 0; | |
let totalDone = 0; | |
let blockedCount = 0; | |
const progressBar = element("div", { className: "custom-sl-progress" }); | |
progressBar.style.setProperty("--progress-margin", "0 0 0 10px"); | |
columns.reverse().forEach((column) => { | |
const isLastColumn = column.id === columns[columns.length - 1].id; | |
if (isLastColumn) { | |
totalOpen = totalSP - column.totalSP; | |
totalDone = column.totalSP; | |
} | |
blockedCount += column.issues.filter((issue) => issue.blocked).length; | |
const columnProgress = element("div", { className: "custom-sl-progress__indicator" }); | |
columnProgress.style.setProperty("--indicator-width", `${column.totalSP * 4}px`); | |
columnProgress.style.setProperty("--indicator-color", getColumnColor(column.name)); | |
progressBar.appendChild(columnProgress); | |
}); | |
const wrapper = element("div", { | |
className: "custom-sl-header__wrapper", | |
children: [ | |
element("div", { | |
children: [`${totalSP} SP (${totalIssues} Tickets) - Open: ${totalOpen} SP - Done: ${totalDone} SP`] | |
}), | |
progressBar, | |
blockedCount > 0 ? element("div", { className: "custom-sl-header__blocked", children: [blockedCount] }) : null | |
] | |
}); | |
const target = swimlaneNode.querySelector(".ghx-description"); | |
target.innerHTML = ""; | |
target.appendChild(wrapper); | |
swimlaneNode.querySelectorAll(".ghx-heading").forEach((heading) => { | |
heading.setAttribute("data-tooltip", `${name} - ${totalDone}/${totalSP} SP (${totalIssues} Tickets)`); | |
}); | |
}); | |
} | |
function renderBoardHeader({ columns }) { | |
const totalSP = columns.reduce((total, column) => total + column.totalSP, 0); | |
columns.forEach(({ name, columnNode, totalSP: columnSP }) => { | |
const percent = Math.round((100 / totalSP) * columnSP); | |
let progressBar = columnNode.querySelector(".custom-sl-progress"); | |
if (!progressBar) { | |
progressBar = element("div", { | |
className: "custom-sl-progress", | |
children: [element("div", { className: "custom-sl-progress__indicator" })] | |
}); | |
progressBar.style.setProperty("--progress-height", "4px"); | |
progressBar.style.setProperty("--progress-width", "100%"); | |
progressBar.style.setProperty("--progress-radius", "0px"); | |
progressBar.style.setProperty("--progress-margin", "auto"); | |
progressBar.style.setProperty("--indicator-color", getColumnColor(name, "header")); | |
columnNode.appendChild(progressBar); | |
} | |
progressBar.style.setProperty("--indicator-width", `${percent}%`); | |
columnNode.setAttribute("title", `${columnSP}/${totalSP} (${percent}%)`); | |
}); | |
} | |
function getColumnColor(columnName, type = "default") { | |
const color = columnColors.find((c) => c.pattern.test(columnName)); | |
return color[type] || color.default || "#35dfe8"; | |
} | |
function injectCustomStyles() { | |
const style = document.createElement("style", { id: "custom-styles" }); | |
style.innerHTML = ` | |
.ghx-heading span[role="button"] { | |
font-weight: 500; | |
} | |
.custom-sl-header__wrapper { | |
display: inline-flex; | |
justify-content: space-between; | |
align-items: center; | |
color: #999; | |
} | |
.custom-sl-progress { | |
--progress-height: 6px; | |
--progress-width: auto; | |
--progress-radius: 3px; | |
--progress-margin: 10px; | |
--progress-bg: #ecf0f1; | |
display: inline-flex; | |
height: var(--progress-height); | |
width: var(--progress-width); | |
border-radius: var(--progress-radius); | |
margin: var(--progress-margin); | |
background: var(--progress-bg); | |
overflow: hidden; | |
padding-bottom: 0 !important; | |
} | |
.custom-sl-progress__indicator { | |
height: var(--progress-height); | |
width: var(--indicator-width); | |
background: var(--indicator-color); | |
flex-shrink: 0; | |
padding-bottom: 0 !important; | |
} | |
.custom-sl-header__blocked { | |
margin-left: 10px; | |
background: #fff5bd; | |
color: #917b00; | |
height: 16px; | |
line-height: 16px; | |
text-align: center; | |
border-radius: 3px; | |
padding: 0 5px; | |
font-weight: bold; | |
} | |
.ghx-column-headers .ghx-column { | |
overflow: hidden; | |
} | |
.ghx-column-headers .ghx-column > div { | |
padding-bottom: 4px; | |
overflow: hidden; | |
} | |
.ghx-column-headers .custom-sl-progress { | |
position: absolute; | |
left: 0; | |
bottom: 0; | |
} | |
`; | |
document.head.appendChild(style); | |
} | |
/* | |
* UTILS | |
*/ | |
function log(...args) { | |
GM_log(...args); | |
} | |
function pipe(...fns) { | |
return (x) => fns.reduce((v, f) => f(v), x); | |
} | |
function find(selector) { | |
return (context) => context?.querySelector(selector); | |
} | |
function all(selector) { | |
return (context) => (context ? Array.from(context.querySelectorAll(selector)) : []); | |
} | |
function map(fn) { | |
return (arr) => arr.map(fn); | |
} | |
function extractText(defaultValue = "") { | |
return (node) => (node ? node.innerText : defaultValue); | |
} | |
function extractAttribute(attributeName, defaultValue = "") { | |
return (node) => (node ? node.getAttribute(attributeName) : defaultValue); | |
} | |
function toNumber(defaultValue = 0) { | |
return (str) => (isNaN(str) ? defaultValue : +str); | |
} | |
function toBool() { | |
return (input) => { | |
if (typeof input === "boolean") return input; | |
if (Array.isArray(input)) return input.length > 0; | |
if (typeof input === "string" && input.trim.toLowerCase() === "false") return false; | |
return !!input; | |
}; | |
} | |
function element(tagName, { className = null, value = null, children = [], ...attributes } = {}) { | |
const node = document.createElement(tagName); | |
if (className) node.className = className; | |
if (value !== null) node.value = value; | |
Object.entries(attributes).forEach(([attrName, attrVal]) => node.setAttribute(attrName, attrVal)); | |
children.forEach((child) => { | |
if (typeof child === "string" || typeof child === "number") child = text(child); | |
if (child && child !== null) node.appendChild(child); | |
}); | |
return node; | |
} | |
function text(text) { | |
return document.createTextNode(text); | |
} | |
start(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment