Skip to content

Instantly share code, notes, and snippets.

@dantodev
Last active August 17, 2023 08:26
Show Gist options
  • Save dantodev/8179ce45abf36a3fbc5bcc815653deeb to your computer and use it in GitHub Desktop.
Save dantodev/8179ce45abf36a3fbc5bcc815653deeb to your computer and use it in GitHub Desktop.
Tampermonkey Script - [Jira] Add Story Points swimlane
// ==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