Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save brunoro/2925b775f97a6ba3242426396d1a697d to your computer and use it in GitHub Desktop.

Select an option

Save brunoro/2925b775f97a6ba3242426396d1a697d to your computer and use it in GitHub Desktop.
GH Projects Burnup Chart Projected End Date
// ==UserScript==
// @name GH Projects Burnup Chart Projected End Date
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Plot projected end date over GH Projects burnup chart, with velocity calculation
// @author brunoro
// @match https://github.com/users/*/projects/*/insights/*
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
// Add CSS for our elements
GM_addStyle(`
.burnup-data-table {
margin-top: 150px;
padding: 15px;
background-color: var(--bgColor-default, #f6f8fa);
border-radius: 6px;
border: 1px solid var(--borderColor-muted, #d0d7de);
}
.projected-line-group line {
stroke: var(--danger-fg, #cf222e);
stroke-width: 2;
stroke-dasharray: 5,5;
}
.projected-end-circle {
fill: var(--danger-fg, #cf222e);
}
.velocity-info {
margin-bottom: 10px;
color: var(--fgColor-default);
}
.projection-info {
margin-bottom: 10px;
color: var(--fgColor-default);
}
`);
// Wait for the page to load
window.addEventListener('load', function() {
setTimeout(extractAndDisplayData, 2000);
});
function extractAndDisplayData() {
// Check if we already added the table
if (document.getElementById('burnup-data-table')) {
return;
}
console.log('Extracting burnup chart data...');
// Try to get data from the chart
const chartData = extractChartData();
if (!chartData) {
console.log('Failed to extract chart data');
return;
}
// Display the data
createDataTable(chartData.dates, chartData.series);
// Calculate projection and draw line
const openSeries = chartData.series.find(s => s.name === 'Open');
const completedSeries = chartData.series.find(s => s.name === 'Completed');
const projection = calculateVelocityAndEndDate(chartData.dates, openSeries, completedSeries);
if (projection) {
drawProjectedLine(chartData.dates, completedSeries, projection);
}
}
function extractChartData() {
// Method 1: Try to get data from marker elements
const markers = document.querySelectorAll('.highcharts-point');
if (markers.length > 0) {
console.log(`Found ${markers.length} marker points`);
return extractDataFromMarkers(markers);
}
// Method 2: Try to get data from path data
const paths = document.querySelectorAll('.highcharts-graph');
if (paths.length >= 2) {
console.log(`Found ${paths.length} path elements`);
return extractDataFromPaths(paths);
}
return null;
}
function extractDataFromMarkers(markers) {
// Group markers by their aria-label to identify series
const openMarkers = [];
const completedMarkers = [];
markers.forEach(marker => {
const ariaLabel = marker.getAttribute('aria-label') || '';
if (ariaLabel.includes('Open')) {
openMarkers.push(marker);
} else if (ariaLabel.includes('Completed')) {
completedMarkers.push(marker);
}
});
// Sort markers by their x position (date order)
const sortMarkersByX = (markers) => {
return markers.map(marker => {
const transform = marker.getAttribute('transform') || '';
const match = transform.match(/translate\(([^,]+),\s*([^)]+)\)/);
return {
marker: marker,
x: match ? parseFloat(match[1]) : 0,
y: match ? parseFloat(match[2]) : 0,
label: marker.getAttribute('aria-label') || ''
};
}).sort((a, b) => a.x - b.x);
};
const sortedOpenMarkers = sortMarkersByX(openMarkers);
const sortedCompletedMarkers = sortMarkersByX(completedMarkers);
// Extract dates from x-axis labels
const dates = extractDatesFromAxis();
if (dates.length === 0) {
console.log('Could not extract dates');
return null;
}
// Get y-axis range
const yAxisInfo = getYAxisInfo();
// Extract data values from markers
const extractPointsFromMarkers = (sortedMarkers, dates) => {
const points = new Array(dates.length).fill(0);
// We'll match markers to dates based on their x position
sortedMarkers.forEach((markerData, index) => {
if (index < dates.length) {
// Extract value from aria-label (e.g., "Nov 10, 117. Open.")
const label = markerData.label;
const valueMatch = label.match(/.*, (\d+)\./);
if (valueMatch) {
console.log(valueMatch);
points[index] = parseFloat(valueMatch[1]);
} else {
// Fallback: estimate from y position
const dataValue = yAxisInfo.max - ((markerData.y - yAxisInfo.translateY) / yAxisInfo.plotHeight) * (yAxisInfo.max - yAxisInfo.min);
points[index] = Math.round(dataValue * 10) / 10;
}
}
});
return points;
};
const openPoints = extractPointsFromMarkers(sortedOpenMarkers, dates);
const completedPoints = extractPointsFromMarkers(sortedCompletedMarkers, dates);
return {
dates: dates,
series: [
{
name: 'Open',
color: '#238636',
points: openPoints
},
{
name: 'Completed',
color: '#8957e5',
points: completedPoints
}
]
};
}
function extractDataFromPaths(paths) {
const dates = extractDatesFromAxis();
if (dates.length === 0) {
console.log('Could not extract dates');
return null;
}
// Get SVG dimensions and y-axis info
const svg = document.querySelector('.highcharts-root');
const plotBackground = svg.querySelector('.highcharts-plot-background');
const plotWidth = parseFloat(plotBackground?.getAttribute('width') || '721');
const plotHeight = parseFloat(plotBackground?.getAttribute('height') || '295');
const yAxisInfo = getYAxisInfo();
const series = [];
// Process each path
paths.forEach((path, idx) => {
const pathData = path.getAttribute('d');
const points = parseSimplePathData(pathData, dates.length, plotWidth, plotHeight, yAxisInfo);
const isOpenSeries = idx === 0 || path.getAttribute('stroke') === '#238636';
series.push({
name: isOpenSeries ? 'Open' : 'Completed',
color: isOpenSeries ? '#238636' : '#8957e5',
points: points
});
});
return { dates, series };
}
function parseSimplePathData(pathData, numDates, plotWidth, plotHeight, yAxisInfo) {
// Extract key points from the path (M and L commands)
const points = new Array(numDates).fill(0);
const commands = pathData.match(/[ML][^ML]*/g) || [];
commands.forEach(command => {
const coords = command.substring(1).trim().split(/[\s,]+/).map(parseFloat);
if (coords.length >= 2) {
const x = coords[0];
const y = coords[1];
// Calculate which date this corresponds to
const dateIndex = Math.min(numDates - 1, Math.max(0, Math.round((x / plotWidth) * (numDates - 1))));
// Convert SVG y to data value
const dataValue = yAxisInfo.max - ((y - yAxisInfo.translateY) / plotHeight) * (yAxisInfo.max - yAxisInfo.min);
// For cumulative charts, take the maximum value for each date
points[dateIndex] = Math.max(points[dateIndex], dataValue);
}
});
// Fill in gaps and ensure cumulative nature
for (let i = 1; i < numDates; i++) {
if (points[i] < points[i-1]) {
points[i] = points[i-1];
}
if (points[i] === 0 && points[i-1] > 0) {
points[i] = points[i-1];
}
}
return points.map(p => Math.round(p * 10) / 10);
}
function extractDatesFromAxis() {
const dates = [];
// Get date range from visible labels
const dateLabels = Array.from(document.querySelectorAll('.highcharts-xaxis-labels text'))
.map(label => ({
text: label.textContent.trim(),
x: parseFloat(label.getAttribute('x') || '0')
}))
.filter(item => item.text && item.text !== '')
.sort((a, b) => a.x - b.x);
if (dateLabels.length < 2) {
return dates;
}
// Parse first and last dates
const firstDate = parseDate(dateLabels[0].text);
const lastDate = parseDate(dateLabels[dateLabels.length - 1].text);
// Generate all dates in between
const currentDate = new Date(firstDate);
while (currentDate <= lastDate) {
dates.push(formatDateShort(currentDate));
currentDate.setDate(currentDate.getDate() + 1);
}
return dates;
}
function getYAxisInfo() {
const yAxisLabels = Array.from(document.querySelectorAll('.highcharts-yaxis-labels text'))
.map(label => parseFloat(label.textContent.trim()))
.filter(num => !isNaN(num));
const min = Math.min(...yAxisLabels);
const max = Math.max(...yAxisLabels);
// Get transform info
const svg = document.querySelector('.highcharts-root');
const seriesGroup = svg.querySelector('.highcharts-series-group');
const translateY = seriesGroup ? parseFloat(seriesGroup.getAttribute('transform')?.match(/translate\([^,]+,\s*([^)]+)/)?.[1] || '0') : 0;
const plotBackground = svg.querySelector('.highcharts-plot-background');
const plotHeight = parseFloat(plotBackground?.getAttribute('height') || '295');
return { min, max, translateY, plotHeight };
}
function calculateVelocityAndEndDate(dates, openSeries, completedSeries) {
// Filter out invalid data points
const validIndices = [];
for (let i = 0; i < completedSeries.points.length; i++) {
if (completedSeries.points[i] !== undefined && !isNaN(completedSeries.points[i])) {
validIndices.push(i);
}
}
if (validIndices.length < 2) {
console.log('Insufficient valid data points for velocity calculation');
return null;
}
// Use data from the beginning to NOW (current date)
const startIndex = validIndices[0];
// Find the data point closest to today
const today = new Date();
today.setHours(0, 0, 0, 0);
let closestIndex = startIndex;
let minDiff = Infinity;
for (let i = startIndex; i < dates.length; i++) {
const chartDate = parseDate(dates[i]);
chartDate.setHours(0, 0, 0, 0);
const diff = Math.abs(today - chartDate);
if (diff < minDiff) {
minDiff = diff;
closestIndex = i;
}
// If we've passed today, stop looking
if (chartDate > today) {
break;
}
}
const endIndex = closestIndex;
// If the closest date is more than 7 days away from today, use the last available data point
const daysDiff = minDiff / (1000 * 60 * 60 * 24);
if (daysDiff > 7 && endIndex < dates.length - 1) {
console.log(`Closest date is ${daysDiff.toFixed(1)} days from today, using last available data point`);
}
const startDate = parseDate(dates[startIndex]);
const endDate = parseDate(dates[endIndex]);
const startPoints = completedSeries.points[startIndex];
const endPoints = completedSeries.points[endIndex];
console.log(`Velocity calculation: ${formatDate(startDate)} to ${formatDate(endDate)} (${dates[endIndex]})`);
// Calculate total points completed in the period
const totalPointsCompleted = endPoints - startPoints;
// Calculate time difference in days
const timeDiffMs = endDate - startDate;
const timeDiffDays = timeDiffMs / (1000 * 60 * 60 * 24);
// Calculate velocity (points per day)
const velocity = totalPointsCompleted / timeDiffDays;
// Get current open points (use the point closest to today)
const currentOpenPoints = openSeries.points[endIndex];
// Calculate remaining days at current velocity
const remainingDays = currentOpenPoints / velocity;
// Calculate predicted end date - start from TODAY
const predictedEndDate = new Date(today);
predictedEndDate.setDate(predictedEndDate.getDate() + remainingDays);
return {
velocity: velocity,
startDate: startDate,
endDate: endDate,
chartEndDate: parseDate(dates[dates.length - 1]),
today: today,
timePeriodDays: timeDiffDays,
totalPointsCompleted: totalPointsCompleted,
currentOpenPoints: currentOpenPoints,
remainingDays: remainingDays,
predictedEndDate: predictedEndDate,
startPoints: startPoints,
endPoints: endPoints,
daysFromTodayToChartEnd: daysDiff
};
}
function parseDate(dateStr) {
if (!dateStr || typeof dateStr !== 'string') {
return new Date();
}
let cleanDateStr = dateStr.trim();
// Add current year if not specified
if (!cleanDateStr.match(/\d{4}/)) {
const currentYear = new Date().getFullYear();
const dateParts = cleanDateStr.split(' ');
const monthStr = dateParts[0];
const currentMonth = new Date().getMonth();
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const dateMonthIndex = monthNames.indexOf(monthStr);
if (dateMonthIndex >= 0 && dateMonthIndex < currentMonth) {
cleanDateStr = cleanDateStr + ' ' + currentYear;
} else {
cleanDateStr = cleanDateStr + ' ' + currentYear;
}
}
const date = new Date(cleanDateStr);
if (isNaN(date.getTime())) {
return new Date();
}
return date;
}
function formatDate(date) {
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
return 'Invalid Date';
}
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function formatDateShort(date) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
}).replace(',', '');
}
function createDataTable(dates, series) {
const headingContainer = document.querySelector('div[class^=insights-chart-view-module__HeadingContainer]');
if (!headingContainer) {
console.log('Could not find heading container');
return;
}
if (document.getElementById('burnup-data-table')) {
return;
}
// Calculate velocity and end date prediction
const openSeries = series.find(s => s.name === 'Open');
const completedSeries = series.find(s => s.name === 'Completed');
const projection = calculateVelocityAndEndDate(dates, openSeries, completedSeries);
// Create table container
const tableContainer = document.createElement('div');
tableContainer.id = 'burnup-data-table';
tableContainer.className = 'burnup-data-table';
// Create title
const title = document.createElement('h3');
title.textContent = 'Burnup Chart Data & Projection';
title.style.marginTop = '0';
title.style.marginBottom = '15px';
title.style.fontSize = '14px';
title.style.fontWeight = '600';
title.style.color = 'var(--fgColor-default)';
tableContainer.appendChild(title);
// Create summary section at the top
const summaryDiv = document.createElement('div');
summaryDiv.style.marginBottom = '20px';
summaryDiv.style.padding = '15px';
summaryDiv.style.backgroundColor = 'var(--bgColor-muted, #f6f8fa)';
summaryDiv.style.borderRadius = '4px';
summaryDiv.style.border = '1px solid var(--borderColor-muted, #d0d7de)';
if (projection) {
const velocityInfo = document.createElement('div');
velocityInfo.className = 'velocity-info';
const velocityText = document.createElement('strong');
velocityText.textContent = `Velocity: ${projection.velocity.toFixed(2)} points/day`;
velocityText.style.color = completedSeries.color;
velocityInfo.appendChild(velocityText);
const periodInfo = document.createElement('div');
periodInfo.style.fontSize = '11px';
periodInfo.style.color = 'var(--fgColor-muted)';
periodInfo.style.marginTop = '4px';
periodInfo.textContent = `Calculation period: ${formatDate(projection.startDate)} to ${formatDate(projection.endDate)} (${projection.timePeriodDays.toFixed(1)} days)`;
velocityInfo.appendChild(periodInfo);
const todayInfo = document.createElement('div');
todayInfo.style.fontSize = '11px';
todayInfo.style.color = 'var(--fgColor-muted)';
todayInfo.style.marginTop = '2px';
todayInfo.textContent = `Today's date: ${formatDate(new Date())}`;
velocityInfo.appendChild(todayInfo);
const pointsInfo = document.createElement('div');
pointsInfo.style.fontSize = '11px';
pointsInfo.style.color = 'var(--fgColor-muted)';
pointsInfo.style.marginTop = '2px';
pointsInfo.textContent = `Points completed in period: ${projection.totalPointsCompleted.toFixed(1)}`;
velocityInfo.appendChild(pointsInfo);
summaryDiv.appendChild(velocityInfo);
const projectionInfo = document.createElement('div');
projectionInfo.className = 'projection-info';
const currentOpenText = document.createElement('div');
currentOpenText.style.marginBottom = '4px';
currentOpenText.textContent = `Current open points: ${projection.currentOpenPoints.toFixed(1)}`;
currentOpenText.style.color = openSeries.color;
projectionInfo.appendChild(currentOpenText);
const remainingDaysText = document.createElement('div');
remainingDaysText.style.marginBottom = '4px';
remainingDaysText.textContent = `Remaining days at current velocity: ${projection.remainingDays.toFixed(1)}`;
projectionInfo.appendChild(remainingDaysText);
const predictedDateText = document.createElement('div');
predictedDateText.style.fontWeight = 'bold';
predictedDateText.style.color = projection.remainingDays > 0 ? 'var(--danger-fg, #cf222e)' : 'var(--success-fg, #1a7f37)';
predictedDateText.textContent = `Predicted completion date: ${formatDate(projection.predictedEndDate)}`;
projectionInfo.appendChild(predictedDateText);
summaryDiv.appendChild(projectionInfo);
} else {
const noDataMsg = document.createElement('div');
noDataMsg.textContent = 'Insufficient data to calculate velocity and projection';
noDataMsg.style.color = 'var(--fgColor-muted)';
noDataMsg.style.fontStyle = 'italic';
summaryDiv.appendChild(noDataMsg);
}
tableContainer.appendChild(summaryDiv);
// Create table
const table = document.createElement('table');
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
table.style.fontSize = '12px';
// Create header
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
const dateHeader = document.createElement('th');
dateHeader.textContent = 'Date';
dateHeader.style.textAlign = 'left';
dateHeader.style.padding = '10px';
dateHeader.style.borderBottom = '2px solid var(--borderColor-muted, #d0d7de)';
dateHeader.style.backgroundColor = 'var(--bgColor-muted, #f6f8fa)';
dateHeader.style.color = 'var(--fgColor-default)';
headerRow.appendChild(dateHeader);
for (const seriesData of series) {
const seriesHeader = document.createElement('th');
seriesHeader.textContent = seriesData.name;
seriesHeader.style.textAlign = 'right';
seriesHeader.style.padding = '10px';
seriesHeader.style.borderBottom = '2px solid var(--borderColor-muted, #d0d7de)';
seriesHeader.style.backgroundColor = 'var(--bgColor-muted, #f6f8fa)';
seriesHeader.style.color = seriesData.color;
headerRow.appendChild(seriesHeader);
}
thead.appendChild(headerRow);
table.appendChild(thead);
// Create table body
const tbody = document.createElement('tbody');
// Show only every Nth date to keep table manageable
const showEveryN = Math.ceil(dates.length / 30);
for (let i = 0; i < dates.length; i++) {
if (showEveryN > 1 && i % showEveryN !== 0 && i !== dates.length - 1) {
continue;
}
const row = document.createElement('tr');
// Date cell
const dateCell = document.createElement('td');
dateCell.textContent = dates[i];
dateCell.style.padding = '10px';
dateCell.style.borderBottom = '1px solid var(--borderColor-muted, #d0d7de)';
dateCell.style.color = 'var(--fgColor-default)';
row.appendChild(dateCell);
// Data cells for each series
for (const seriesData of series) {
const dataCell = document.createElement('td');
dataCell.textContent = seriesData.points[i] !== undefined ? seriesData.points[i].toFixed(1) : 'N/A';
dataCell.style.textAlign = 'right';
dataCell.style.padding = '10px';
dataCell.style.borderBottom = '1px solid var(--borderColor-muted, #d0d7de)';
dataCell.style.fontFamily = 'monospace';
dataCell.style.color = seriesData.color;
row.appendChild(dataCell);
}
// Alternate row background
if (i % 2 === 0) {
row.style.backgroundColor = 'var(--bgColor-default, #ffffff)';
} else {
row.style.backgroundColor = 'var(--bgColor-muted, #f6f8fa)';
}
tbody.appendChild(row);
}
table.appendChild(tbody);
tableContainer.appendChild(table);
// Add series summary at the bottom
const seriesSummaryDiv = document.createElement('div');
seriesSummaryDiv.style.marginTop = '15px';
seriesSummaryDiv.style.fontSize = '12px';
seriesSummaryDiv.style.color = 'var(--fgColor-muted)';
for (const seriesData of series) {
const points = seriesData.points.filter(p => p !== undefined && !isNaN(p));
if (points.length > 0) {
const lastPoint = points[points.length - 1];
const firstPoint = points[0];
const change = lastPoint - firstPoint;
const summaryText = document.createElement('div');
summaryText.style.marginBottom = '4px';
summaryText.innerHTML = `<span style="color:${seriesData.color}">${seriesData.name}</span>: ${firstPoint.toFixed(1)} → ${lastPoint.toFixed(1)} <span style="color:${change >= 0 ? 'var(--success-fg, #1a7f37)' : 'var(--danger-fg, #cf222e)'}">(${change >= 0 ? '+' : ''}${change.toFixed(1)})</span>`;
seriesSummaryDiv.appendChild(summaryText);
}
}
tableContainer.appendChild(seriesSummaryDiv);
// Insert the table after the heading container
headingContainer.parentNode.insertBefore(tableContainer, headingContainer.nextSibling);
console.log('Burnup chart data table created successfully');
}
function drawProjectedLine(dates, completedSeries, projection) {
const svg = document.querySelector('.highcharts-root');
if (!svg || !projection) return;
// Remove any existing projected line
const existingLine = svg.querySelector('.projected-line-group');
if (existingLine) {
existingLine.remove();
}
// Get SVG dimensions
const plotBackground = svg.querySelector('.highcharts-plot-background');
const plotX = parseFloat(plotBackground?.getAttribute('x') || '37');
const plotY = parseFloat(plotBackground?.getAttribute('y') || '4');
const plotWidth = parseFloat(plotBackground?.getAttribute('width') || '721');
const plotHeight = parseFloat(plotBackground?.getAttribute('height') || '295');
// Find the series group
const seriesGroup = svg.querySelector('.highcharts-series-group');
if (!seriesGroup) return;
// Create a new group for our projected line
const projectedGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
projectedGroup.setAttribute('class', 'projected-line-group');
projectedGroup.setAttribute('transform', `translate(${plotX}, ${plotY})`);
// Get y-axis range
const yAxisLabels = Array.from(document.querySelectorAll('.highcharts-yaxis-labels text'))
.map(label => parseFloat(label.textContent.trim()))
.filter(num => !isNaN(num));
const yMin = Math.min(...yAxisLabels);
const yMax = Math.max(...yAxisLabels);
// Calculate the y position for the predicted end date
// The predicted completed points = current completed points + remaining open points
// But actually, at completion, completed points = total points (open + completed)
const totalPointsAtCompletion = completedSeries.points[completedSeries.points.length - 1] + projection.currentOpenPoints;
const predictedCompletedValue = totalPointsAtCompletion;
// Convert to y position in SVG coordinates
const svgYForCompletion = plotHeight * (1 - (predictedCompletedValue / yMax));
// Convert dates to positions
const startDate = parseDate(dates[0]);
const today = new Date();
today.setHours(0, 0, 0, 0);
const predictedTime = projection.predictedEndDate.getTime();
// Calculate x positions
const dateRangeMs = parseDate(dates[dates.length - 1]).getTime() - startDate.getTime();
const totalDateRangeMs = dateRangeMs; // For the existing chart
// Start point (first date in data)
const startX = 0;
const startCompletedValue = completedSeries.points[0];
const startY = plotHeight * (1 - (startCompletedValue / yMax));
// Today's point (where we are now)
const todayX = plotWidth * ((today.getTime() - startDate.getTime()) / totalDateRangeMs);
const currentCompletedValue = completedSeries.points[completedSeries.points.length - 1];
const currentY = plotHeight * (1 - (currentCompletedValue / yMax));
// Predicted end point
let predictedX;
if (predictedTime <= parseDate(dates[dates.length - 1]).getTime()) {
// Predicted date is within the current chart range
predictedX = plotWidth * ((predictedTime - startDate.getTime()) / totalDateRangeMs);
} else {
// Predicted date is beyond the current chart
// Extend the x-axis proportionally
const extendedDateRangeMs = predictedTime - startDate.getTime();
predictedX = plotWidth * (totalDateRangeMs / extendedDateRangeMs) * plotWidth;
}
// Cap predictedX at plotWidth if it goes beyond
const displayX = Math.min(predictedX, plotWidth);
// Draw the regression line from start to predicted point
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', startX);
line.setAttribute('y1', startY);
line.setAttribute('x2', displayX);
line.setAttribute('y2', svgYForCompletion);
line.setAttribute('stroke', 'var(--danger-fg, #cf222e)');
line.setAttribute('stroke-width', '2');
line.setAttribute('stroke-dasharray', '5,5');
projectedGroup.appendChild(line);
// Draw the circle at predicted end date if within graph
if (predictedX <= plotWidth) {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', displayX);
circle.setAttribute('cy', svgYForCompletion);
circle.setAttribute('r', '6');
circle.setAttribute('fill', 'var(--danger-fg, #cf222e)');
circle.setAttribute('class', 'projected-end-circle');
projectedGroup.appendChild(circle);
// Draw the label
const labelGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
labelGroup.setAttribute('transform', `translate(${displayX}, ${svgYForCompletion + 40})`);
const labelText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelText.setAttribute('text-anchor', 'middle');
labelText.setAttribute('fill', 'var(--danger-fg, #cf222e)');
labelText.setAttribute('font-size', '11');
labelText.setAttribute('font-weight', 'bold');
labelText.setAttribute('y', '0');
labelText.textContent = formatDateShort(projection.predictedEndDate);
labelGroup.appendChild(labelText);
const pointsText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
pointsText.setAttribute('text-anchor', 'middle');
pointsText.setAttribute('fill', 'var(--fgColor-muted)');
pointsText.setAttribute('font-size', '10');
pointsText.setAttribute('y', '15');
pointsText.textContent = `${predictedCompletedValue.toFixed(0)} points`;
labelGroup.appendChild(pointsText);
projectedGroup.appendChild(labelGroup);
} else if (predictedX > plotWidth) {
// Draw arrow pointing right at the edge
const arrowY = svgYForCompletion;
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
arrow.setAttribute('points', `${plotWidth-10},${arrowY-5} ${plotWidth},${arrowY} ${plotWidth-10},${arrowY+5}`);
arrow.setAttribute('fill', 'var(--danger-fg, #cf222e)');
projectedGroup.appendChild(arrow);
// Add text note at the edge
const noteGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
noteGroup.setAttribute('transform', `translate(${plotWidth - 100}, ${arrowY - 40})`);
const noteRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
noteRect.setAttribute('x', '-90');
noteRect.setAttribute('y', '-15');
noteRect.setAttribute('width', '180');
noteRect.setAttribute('height', '30');
noteRect.setAttribute('rx', '4');
noteRect.setAttribute('ry', '4');
noteRect.setAttribute('fill', 'var(--bgColor-default, #ffffff)');
noteRect.setAttribute('stroke', 'var(--danger-fg, #cf222e)');
noteRect.setAttribute('stroke-width', '1');
noteGroup.appendChild(noteRect);
const noteText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
noteText.setAttribute('text-anchor', 'middle');
noteText.setAttribute('fill', 'var(--danger-fg, #cf222e)');
noteText.setAttribute('font-size', '10');
noteText.setAttribute('font-weight', 'bold');
noteText.setAttribute('y', '5');
noteText.textContent = `${formatDateShort(projection.predictedEndDate)} (${predictedCompletedValue.toFixed(0)} pts)`;
noteGroup.appendChild(noteText);
projectedGroup.appendChild(noteGroup);
}
// Add the projected line group to the SVG
seriesGroup.parentNode.insertBefore(projectedGroup, seriesGroup.nextSibling);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment