Created
December 3, 2025 14:21
-
-
Save brunoro/2925b775f97a6ba3242426396d1a697d to your computer and use it in GitHub Desktop.
GH Projects Burnup Chart Projected End Date
This file contains hidden or 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 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