Skip to content

Commit b7d6a42

Browse files
authored
Merge pull request #61 from pfizer-opensource/work-item-age-chart
Add Work Item Age chart
2 parents 2087dcc + e28dab1 commit b7d6a42

File tree

4 files changed

+324
-1
lines changed

4 files changed

+324
-1
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { calculateDaysBetweenDates } from '../../utils/utils.js';
2+
3+
/**
4+
* Class representing a Work Item Graph Data
5+
*/
6+
class WorkItemAgeGraph {
7+
constructor(data, states = ['analysis_active', 'analysis_done', 'in_progress', 'dev_complete', 'verification_start', 'delivered']) {
8+
this.data = data;
9+
this.states = states;
10+
}
11+
12+
computeDataSet() {
13+
const dataSet = [];
14+
this.data.forEach((ticket) => {
15+
const ticketStates = this.#getTheFirstAndLastAvailableStates(ticket);
16+
const diff = calculateDaysBetweenDates(ticketStates.initialStateTimestamp, ticketStates.currentStateTimestamp);
17+
const workItemAge = {
18+
age: diff.roundedDays + 1,
19+
ticketId: ticket.work_id,
20+
ticket_type: ticket.indexes?.find((i) => i.name === 'ticket_type')?.value || '',
21+
...ticketStates,
22+
};
23+
if (isNaN(workItemAge.age) || workItemAge.age <= 0) {
24+
console.warn('Invalid age:', workItemAge.age, 'Ticket has incorrect timestamps', ticket);
25+
return;
26+
}
27+
dataSet.push(workItemAge);
28+
});
29+
30+
dataSet.sort((t1, t2) => this.states.indexOf(t1.currentState) - this.states.indexOf(t2.currentState));
31+
return dataSet;
32+
}
33+
34+
#getTheFirstAndLastAvailableStates(ticket) {
35+
let ticketStates = {};
36+
this.states.forEach((s) => {
37+
if (ticket[s]) {
38+
ticketStates.currentState = s;
39+
ticketStates.currentStateTimestamp = Date.now() / 1000;
40+
if (!ticketStates.initialStateTimestamp) {
41+
ticketStates.initialState = s;
42+
ticketStates.initialStateTimestamp = ticket[s];
43+
}
44+
}
45+
});
46+
return ticketStates;
47+
}
48+
}
49+
50+
export default WorkItemAgeGraph;
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import * as d3 from 'd3';
2+
import styles from '../tooltipStyles.module.css';
3+
import Renderer from '../Renderer.js';
4+
5+
class WorkItemAgeRenderer extends Renderer {
6+
color = '#0ea5e9';
7+
xAxisLabel = 'Work item states';
8+
yAxisLabel = 'Age(days)';
9+
dotRadius = 7;
10+
timeScale = 'logarithmic';
11+
12+
constructor(
13+
data,
14+
workTicketsURL,
15+
states = ['analysis_active', 'analysis_done', 'in_progress', 'dev_complete', 'verification_start', 'delivered']
16+
) {
17+
const filteredData = data.filter((d) => d.currentState !== 'delivered');
18+
super(filteredData);
19+
this.states = states.filter((d) => d !== 'delivered');
20+
this.data = this.groupData(filteredData);
21+
this.workTicketsURL = workTicketsURL;
22+
}
23+
24+
groupData(data) {
25+
const groupedData = data.reduce((acc, item) => {
26+
let group = acc.find((g) => g.currentState === item.currentState && g.age === item.age);
27+
if (!group) {
28+
group = { currentState: item.currentState, age: item.age, items: [] };
29+
acc.push(group);
30+
}
31+
group.items.push(item);
32+
return acc;
33+
}, []);
34+
groupedData.sort((t1, t2) => this.states.indexOf(t1.currentState) - this.states.indexOf(t2.currentState));
35+
return groupedData;
36+
}
37+
38+
renderGraph(graphElementSelector) {
39+
this.drawSvg(graphElementSelector);
40+
this.drawAxes();
41+
this.drawArea();
42+
}
43+
44+
drawSvg(graphElementSelector) {
45+
this.svg = this.createSvg(graphElementSelector);
46+
}
47+
48+
drawArea() {
49+
this.computeDotPositions();
50+
51+
// Add vertical grid lines to delimit state areas
52+
this.svg
53+
.selectAll('.state-delimiter')
54+
.data(this.states)
55+
.enter()
56+
.append('line')
57+
.attr('class', 'state-delimiter')
58+
.attr('x1', (d) => this.x(d))
59+
.attr('x2', (d) => this.x(d))
60+
.attr('y1', 0)
61+
.attr('y2', this.height)
62+
.attr('stroke', '#ccc')
63+
.attr('stroke-dasharray', '4 2');
64+
65+
// Draw dots
66+
this.svg
67+
.selectAll('.dot')
68+
.data(this.data)
69+
.enter()
70+
.append('circle')
71+
.attr('class', 'dot')
72+
.style('cursor', 'pointer')
73+
.attr('id', (d) => `age-${d.ticketId}`)
74+
.attr('cx', (d) => d.xJitter)
75+
.attr('cy', (d) => this.y(d.age))
76+
.attr('r', this.dotRadius)
77+
.attr('fill', 'steelblue')
78+
.on('click', (event, d) => this.handleMouseClickEvent(event, d));
79+
80+
// Add numbers inside the dots
81+
this.svg
82+
.selectAll('.dot-label')
83+
.data(this.data)
84+
.enter()
85+
.append('text')
86+
.attr('class', 'dot-label')
87+
.attr('x', (d) => d.xJitter)
88+
.attr('y', (d) => this.y(d.age))
89+
.attr('dy', '0.35em')
90+
.attr('text-anchor', 'middle')
91+
.attr('font-size', '10px')
92+
.style('cursor', 'pointer')
93+
.style('fill', 'white')
94+
.text((d) => d.items.length)
95+
.on('click', (event, d) => this.handleMouseClickEvent(event, d));
96+
}
97+
98+
computeDotPositions() {
99+
const groupedData = d3.group(this.data, (d) => d.currentState);
100+
101+
// Generate x positions for dots within each state
102+
const stateWidth = this.x.bandwidth();
103+
const jitterRange = stateWidth - this.dotRadius * 2; // Adjust range to prevent overlap
104+
105+
groupedData.forEach((group, state) => {
106+
// Generate evenly spaced positions within the jitter range
107+
let horizontalPositions = d3.range(group.length).map((i) => i * (this.dotRadius * 2) - jitterRange / 2);
108+
109+
// Shuffle positions for randomness
110+
horizontalPositions = d3.shuffle(horizontalPositions);
111+
112+
group.forEach((item, index) => {
113+
// Clamp positions to keep dots inside the band
114+
const xPosition = horizontalPositions[index];
115+
const clampedX = Math.max(-jitterRange / 2 + this.dotRadius, Math.min(jitterRange / 2 - this.dotRadius, xPosition));
116+
117+
// Assign xJitter, ensuring the dot stays within the band
118+
item.xJitter = this.x(state) + stateWidth / 2 + clampedX;
119+
});
120+
});
121+
}
122+
123+
setTimeScaleListener(timeScaleSelector) {
124+
this.timeScaleSelectElement = document.querySelector(timeScaleSelector);
125+
if (this.timeScaleSelectElement) {
126+
this.timeScaleSelectElement.value = this.timeScale;
127+
this.timeScaleSelectElement.addEventListener('change', (event) => {
128+
this.timeScale = event.target.value;
129+
this.computeYScale();
130+
this.updateChartArea(this.selectedTimeRange);
131+
});
132+
}
133+
}
134+
135+
updateChartArea() {
136+
this.drawYAxis(this.gy, this.y);
137+
this.computeDotPositions();
138+
this.svg
139+
.selectAll(`.dot`)
140+
.attr('cx', (d) => d.xJitter)
141+
.attr('cy', (d) => this.y(d.age));
142+
this.svg
143+
.selectAll(`.dot-label`)
144+
.attr('x', (d) => d.xJitter)
145+
.attr('y', (d) => this.y(d.age));
146+
}
147+
148+
drawAxes() {
149+
this.computeXScale();
150+
this.computeYScale();
151+
this.gx = this.svg.append('g');
152+
this.gy = this.svg.append('g');
153+
this.drawXAxis(this.gx, this.x);
154+
this.drawYAxis(this.gy, this.y);
155+
this.drawAxesLabels(this.svg, this.xAxisLabel, this.yAxisLabel);
156+
}
157+
158+
computeYScale() {
159+
if (this.timeScale === 'logarithmic') {
160+
this.y = d3
161+
.scaleLog()
162+
.domain([1, d3.max(this.data, (d) => d.age)])
163+
.range([this.height, 0]);
164+
} else if (this.timeScale === 'linear') {
165+
this.y = d3
166+
.scaleLinear()
167+
.domain([0, d3.max(this.data, (d) => d.age)])
168+
.range([this.height, 0]);
169+
}
170+
}
171+
172+
computeXScale() {
173+
this.x = d3.scaleBand().domain(this.states).range([0, this.width]).padding(0);
174+
}
175+
176+
drawXAxis(gx, x) {
177+
gx.attr('transform', `translate(0,${this.height})`)
178+
.call(d3.axisBottom(x))
179+
.selectAll('text')
180+
.attr('class', 'axis-label')
181+
.style('text-anchor', 'middle');
182+
}
183+
184+
drawYAxis(gy, y) {
185+
gy.call(d3.axisLeft(y)).selectAll('text').attr('class', 'axis-label');
186+
}
187+
188+
showTooltip(event) {
189+
console.log(event);
190+
!this.tooltip && this.#createTooltip();
191+
this.#clearTooltipContent();
192+
this.#positionTooltip(event.tooltipLeft, event.tooltipTop);
193+
this.populateTooltip(event);
194+
this.tooltip.on('mouseleave', () => this.setupMouseLeaveHandler());
195+
}
196+
197+
/**
198+
* Hides the tooltip.
199+
*/
200+
hideTooltip() {
201+
this.tooltip?.transition().duration(100).style('opacity', 0).style('pointer-events', 'none');
202+
}
203+
204+
/**
205+
* Creates a tooltip for the chart used for the observation logging.
206+
* @private
207+
*/
208+
#createTooltip() {
209+
this.tooltip = d3.select('body').append('div').attr('class', styles.chartTooltip).attr('id', 's-tooltip').style('opacity', 0);
210+
}
211+
212+
/**
213+
* Populates the tooltip's content with event data: ticket id and observation body
214+
* @private
215+
* @param {Object} event - The event data for the tooltip.
216+
*/
217+
populateTooltip(event) {
218+
this.tooltip.style('pointer-events', 'auto').style('opacity', 0.9);
219+
this.tooltip.append('p').text(`Age: ${event.age}`);
220+
event.items.forEach((item) => {
221+
this.tooltip
222+
.append('div')
223+
.append('a')
224+
.style('text-decoration', 'underline')
225+
.attr('href', `${this.workTicketsURL}/${item.ticketId}`)
226+
.text(item.ticketId)
227+
.attr('target', '_blank');
228+
});
229+
}
230+
231+
/**
232+
* Positions the tooltip on the page.
233+
* @private
234+
* @param {number} left - The left position for the tooltip.
235+
* @param {number} top - The top position for the tooltip.
236+
*/
237+
#positionTooltip(left, top) {
238+
this.tooltip.transition().duration(100).style('opacity', 0.9).style('pointer-events', 'auto');
239+
this.tooltip.style('left', left + 'px').style('top', top + 'px');
240+
}
241+
242+
/**
243+
* Clears the content of the tooltip.
244+
* @private
245+
*/
246+
#clearTooltipContent() {
247+
this.tooltip.selectAll('*').remove();
248+
}
249+
250+
handleMouseClickEvent(event, d) {
251+
let data = {
252+
...d,
253+
tooltipLeft: event.pageX,
254+
tooltipTop: event.pageY,
255+
};
256+
257+
this.showTooltip(data);
258+
}
259+
260+
setupMouseLeaveHandler() {
261+
d3.select(this.svg.node().parentNode).on('mouseleave', (event) => {
262+
if (event.relatedTarget !== this.tooltip?.node()) {
263+
this.hideTooltip();
264+
}
265+
});
266+
}
267+
}
268+
269+
export default WorkItemAgeRenderer;

src/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import MovingRangeGraph from './graphs/moving-range/MovingRangeGraph.js';
77
import MovingRangeRenderer from './graphs/moving-range/MovingRangeRenderer.js';
88
import ControlRenderer from './graphs/control-chart/ControlRenderer.js';
99
import HistogramRenderer from './graphs/histogram/HistogramRenderer.js';
10+
import WorkItemAgeGraph from './graphs/work-item-age/WorkItemAgeGraph.js';
11+
import WorkItemAgeRenderer from './graphs/work-item-age/WorkItemAgeRenderer.js';
1012
import { eventBus } from './utils/EventBus.js';
1113
import { processServiceData } from './data-processor.js';
1214
import ObservationLoggingService from './graphs/ObservationLoggingService.js';
@@ -21,6 +23,8 @@ export {
2123
MovingRangeRenderer,
2224
ControlRenderer,
2325
HistogramRenderer,
26+
WorkItemAgeGraph,
27+
WorkItemAgeRenderer,
2428
ObservationLoggingService,
2529
eventBus,
2630
processServiceData,

src/utils/utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function calculateDaysBetweenDates(startDate, endDate, roundDown = true)
2727
const startMillis = startDate instanceof Date ? startDate.getTime() : startDate * 1000;
2828
const endMillis = endDate instanceof Date ? endDate.getTime() : endDate * 1000;
2929
const diffDays = (endMillis - startMillis) / (1000 * 3600 * 24);
30-
return { roundedDays: roundDown ? Math.floor(diffDays) : diffDays, exactTimeDiff: parseFloat(diffDays.toFixed(2)) };
30+
return { roundedDays: roundDown ? Math.ceil(diffDays) : diffDays, exactTimeDiff: parseFloat(diffDays.toFixed(2)) };
3131
}
3232

3333
export function areDatesEqual(date1, date2) {

0 commit comments

Comments
 (0)