@@ -3,6 +3,77 @@ const { PR_STATS_REPOS } = require('./constants');
33const ORG = 'learningequality' ;
44const ROLLING_WINDOW_DAYS = 30 ;
55
6+ /**
7+ * Unicode block characters for sparklines, from lowest to highest.
8+ */
9+ const SPARKLINE_CHARS = [ '▁' , '▂' , '▃' , '▄' , '▅' , '▆' , '▇' , '█' ] ;
10+
11+ /**
12+ * Generate a sparkline string from an array of numeric values.
13+ * Maps each value to a Unicode block character based on its relative position
14+ * between the min and max values.
15+ */
16+ function sparkline ( values ) {
17+ if ( ! values || values . length === 0 ) return '' ;
18+
19+ const min = Math . min ( ...values ) ;
20+ const max = Math . max ( ...values ) ;
21+
22+ // If all values are the same, return middle-height bars
23+ if ( max === min ) {
24+ return SPARKLINE_CHARS [ 3 ] . repeat ( values . length ) ;
25+ }
26+
27+ return values
28+ . map ( value => {
29+ // Normalize to 0-1 range
30+ const normalized = ( value - min ) / ( max - min ) ;
31+ // Map to character index (0-7)
32+ const index = Math . min ( Math . floor ( normalized * SPARKLINE_CHARS . length ) , SPARKLINE_CHARS . length - 1 ) ;
33+ return SPARKLINE_CHARS [ index ] ;
34+ } )
35+ . join ( '' ) ;
36+ }
37+
38+ /**
39+ * Create a histogram from an array of values.
40+ * Returns an array of bin counts.
41+ */
42+ function histogram ( values , numBins = 10 ) {
43+ if ( ! values || values . length === 0 ) return [ ] ;
44+
45+ const min = Math . min ( ...values ) ;
46+ const max = Math . max ( ...values ) ;
47+
48+ // If all values are the same, put them all in one bin
49+ if ( max === min ) {
50+ const bins = new Array ( numBins ) . fill ( 0 ) ;
51+ bins [ Math . floor ( numBins / 2 ) ] = values . length ;
52+ return bins ;
53+ }
54+
55+ const binWidth = ( max - min ) / numBins ;
56+ const bins = new Array ( numBins ) . fill ( 0 ) ;
57+
58+ values . forEach ( value => {
59+ let binIndex = Math . floor ( ( value - min ) / binWidth ) ;
60+ // Handle edge case where value equals max
61+ if ( binIndex >= numBins ) binIndex = numBins - 1 ;
62+ bins [ binIndex ] ++ ;
63+ } ) ;
64+
65+ return bins ;
66+ }
67+
68+ /**
69+ * Generate a distribution sparkline from raw data values.
70+ * Creates a histogram and converts bin counts to a sparkline.
71+ */
72+ function distributionSparkline ( values , numBins = 10 ) {
73+ const bins = histogram ( values , numBins ) ;
74+ return sparkline ( bins ) ;
75+ }
76+
677/**
778 * Calculate percentile value from a sorted array of numbers.
879 * Uses linear interpolation between closest ranks.
@@ -253,6 +324,7 @@ module.exports = async ({ github, core }) => {
253324 slackMessage += `*Time to First Review*\n` ;
254325 if ( timeToFirstReviewValues . length > 0 ) {
255326 slackMessage += `Median: ${ formatDuration ( timeToReviewMedian ) } | 95th percentile: ${ formatDuration ( timeToReviewP95 ) } \n` ;
327+ slackMessage += `Distribution: ${ distributionSparkline ( timeToFirstReviewValues ) } \n` ;
256328 slackMessage += `_Based on ${ totalReviewedPRs } reviewed PRs_\n\n` ;
257329 } else {
258330 slackMessage += `_No reviewed PRs in this period_\n\n` ;
@@ -261,6 +333,7 @@ module.exports = async ({ github, core }) => {
261333 slackMessage += `*PR Lifespan (Open to Close/Merge)*\n` ;
262334 if ( lifespanValues . length > 0 ) {
263335 slackMessage += `Median: ${ formatDuration ( lifespanMedian ) } | 95th percentile: ${ formatDuration ( lifespanP95 ) } \n` ;
336+ slackMessage += `Distribution: ${ distributionSparkline ( lifespanValues ) } \n` ;
264337 slackMessage += `_Based on ${ totalClosedPRs } closed/merged PRs_\n\n` ;
265338 } else {
266339 slackMessage += `_No closed PRs in this period_\n\n` ;
0 commit comments