@@ -142,6 +142,20 @@ function cfrTier(pct) {
142142 return "low" ;
143143}
144144
145+ function largePrsTier ( perWeek ) {
146+ if ( perWeek == null ) return "na" ;
147+ if ( perWeek < 2 ) return "elite" ; // <2/wk: healthy churn
148+ if ( perWeek <= 4 ) return "medium" ; // 2-4/wk: caution
149+ return "low" ; // >4/wk: too many big PRs
150+ }
151+
152+ function hotfixTier ( perWeek ) {
153+ if ( perWeek == null ) return "na" ;
154+ if ( perWeek < 1 ) return "elite" ; // 0/wk
155+ if ( perWeek <= 2 ) return "medium" ; // 1-2/wk
156+ return "low" ; // 3+/wk
157+ }
158+
145159const TIER_LABEL = { elite : "Elite" , high : "High" , medium : "Medium" , low : "Low" , na : "N/A" } ;
146160
147161function kpiCard ( { label, value, unit, subText, tier, info } ) {
@@ -212,27 +226,31 @@ function render() {
212226function renderForRepo ( metrics , repo ) {
213227 resetCharts ( ) ;
214228
215- const freqPrs = inRange ( filterByRepo ( metrics [ "deploy-freq-prs" ] || [ ] , repo ) ) ;
216- const freqDeploys = inRange ( filterByRepo ( metrics [ "deploy-freq" ] || [ ] , repo ) ) ;
217- const leadTime = inRange ( filterByRepo ( metrics [ "lead-time" ] || [ ] , repo ) ) ;
218- const cfr = inRange ( filterByRepo ( metrics [ "change-failure-rate" ] || [ ] , repo ) ) ;
219- const cfrPrs = inRange ( filterByRepo ( metrics [ "change-failure-prs" ] || [ ] , repo ) ) ;
220- const hotfixes = inRangeHotfixes ( filterByRepo ( metrics [ "hotfixes" ] || [ ] , repo ) ) ;
221- const reviewLatency = inRange ( filterByRepo ( metrics [ "review-latency" ] || [ ] , repo ) ) ;
229+ const freqPrs = inRange ( filterByRepo ( metrics [ "deploy-freq-prs" ] || [ ] , repo ) ) ;
230+ const freqDeploys = inRange ( filterByRepo ( metrics [ "deploy-freq" ] || [ ] , repo ) ) ;
231+ const leadTime = inRange ( filterByRepo ( metrics [ "lead-time" ] || [ ] , repo ) ) ;
232+ const cfr = inRange ( filterByRepo ( metrics [ "change-failure-rate" ] || [ ] , repo ) ) ;
233+ const cfrPrs = inRange ( filterByRepo ( metrics [ "change-failure-prs" ] || [ ] , repo ) ) ;
234+ const hotfixes = inRangeHotfixes ( filterByRepo ( metrics [ "hotfixes" ] || [ ] , repo ) ) ;
235+ const reviewLatency = inRange ( filterByRepo ( metrics [ "review-latency" ] || [ ] , repo ) ) ;
236+ const largePrs = inRange ( filterByRepo ( metrics [ "large-prs" ] || [ ] , repo ) ) ;
237+ const hotfixCount = inRange ( filterByRepo ( metrics [ "hotfix-count" ] || [ ] , repo ) ) ;
238+ const weekendMerges = inRangeWeekendMerges ( filterByRepo ( metrics [ "weekend-merges" ] || [ ] , repo ) ) ;
222239 // summary is not date-filterable; renderKPIs uses it only as a fallback,
223240 // and that fallback is dropped when filtering is active (see renderKPIs).
224- const summary = filterByRepo ( metrics [ "summary" ] || [ ] , repo ) ;
241+ const summary = filterByRepo ( metrics [ "summary" ] || [ ] , repo ) ;
225242
226- renderKPIs ( summary , freqPrs , freqDeploys , leadTime , cfr ) ;
243+ renderKPIs ( summary , freqPrs , freqDeploys , leadTime , cfr , largePrs , hotfixCount ) ;
227244 renderFreqChart ( freqPrs , freqDeploys ) ;
228245 renderLeadChart ( leadTime ) ;
229246 renderCFRChart ( cfr ) ;
230247 renderCfrPrs ( cfrPrs ) ;
231248 renderHotfixes ( hotfixes ) ;
232249 renderReviewLatencyChart ( reviewLatency ) ;
250+ renderWeekendMerges ( weekendMerges ) ;
233251}
234252
235- function renderKPIs ( summary , freqPrs , freqDeploys , leadTime , cfr ) {
253+ function renderKPIs ( summary , freqPrs , freqDeploys , leadTime , cfr , largePrs , hotfixCount ) {
236254 const filtering = currentFrom !== null && currentTo !== null ;
237255 const s = filtering ? null : summary [ 0 ] ;
238256
@@ -267,6 +285,23 @@ function renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr) {
267285 totals . d > 0 ? ( 100 * totals . f ) / totals . d
268286 : ( s ?. cfr != null ? parseFloat ( String ( s . cfr ) . replace ( "%" , "" ) ) : null ) ;
269287
288+ // Large PRs / wk and Hotfixes / wk: average per week.
289+ // Unfiltered → last 4 weeks; filtered → range avg.
290+ // Empty + filtering → 0 (the metric only emits weeks with non-zero counts,
291+ // so an empty filtered range legitimately means "none in this window").
292+ // Empty + unfiltered → "—" (metric likely absent from the report entirely).
293+ const lpRows = filtering ? ( largePrs || [ ] ) : recentN ( largePrs || [ ] , 4 ) ;
294+ const lpDenom = filtering ? Math . max ( 1 , lpRows . length ) : 4 ;
295+ const largePerWk = ( largePrs && largePrs . length )
296+ ? lpRows . reduce ( ( a , r ) => a + ( r . large_prs || 0 ) , 0 ) / lpDenom
297+ : ( filtering ? 0 : null ) ;
298+
299+ const hcRows = filtering ? ( hotfixCount || [ ] ) : recentN ( hotfixCount || [ ] , 4 ) ;
300+ const hcDenom = filtering ? Math . max ( 1 , hcRows . length ) : 4 ;
301+ const hotfixPerWk = ( hotfixCount && hotfixCount . length )
302+ ? hcRows . reduce ( ( a , r ) => a + ( r . hotfix_count || 0 ) , 0 ) / hcDenom
303+ : ( filtering ? 0 : null ) ;
304+
270305 const subText = filtering ? "in selected range" : "last 4 weeks" ;
271306 const leadSubText = filtering ? "median, in selected range" : "median, last 4 wk" ;
272307 const cfrSubText = filtering ? "in selected range" : "across window" ;
@@ -298,6 +333,22 @@ function renderKPIs(summary, freqPrs, freqDeploys, leadTime, cfr) {
298333 tier : cfrTier ( cfrPct ) ,
299334 info : "PRs labelled `caused-incident` ÷ all merged PRs across the window. Apply the label to the PR that SHIPPED the defect (not the PR that fixed it). See the drill-down list below the chart." ,
300335 } ) ,
336+ kpiCard ( {
337+ label : "Large PRs / wk" ,
338+ value : largePerWk != null ? largePerWk . toFixed ( 1 ) : "—" ,
339+ unit : "" ,
340+ subText,
341+ tier : largePrsTier ( largePerWk ) ,
342+ info : "Average per-week count of merged PRs with 10 or more changed files. Large PRs are slower to review and more failure-prone — a sustained rate above ~2/wk is a smell." ,
343+ } ) ,
344+ kpiCard ( {
345+ label : "Hotfixes / wk" ,
346+ value : hotfixPerWk != null ? hotfixPerWk . toFixed ( 1 ) : "—" ,
347+ unit : "" ,
348+ subText,
349+ tier : hotfixTier ( hotfixPerWk ) ,
350+ info : "Average per-week count of merged PRs labelled `hotfix`. The `hotfix` label marks the PR that FIXED a prior defect; consistently nonzero rates point to upstream quality issues." ,
351+ } ) ,
301352 kpiCard ( {
302353 label : "Mean time to restore" ,
303354 value : "—" ,
@@ -524,6 +575,50 @@ function renderHotfixes(rows) {
524575 el . innerHTML = html ;
525576}
526577
578+ function renderWeekendMerges ( rows ) {
579+ const el = document . getElementById ( "weekend-merges" ) ;
580+ if ( ! rows . length ) {
581+ el . innerHTML = '<div class="empty">No weekend merges in the current range</div>' ;
582+ return ;
583+ }
584+
585+ // Per-author tally for the selected range.
586+ const byAuthor = new Map ( ) ;
587+ for ( const r of rows ) {
588+ const a = r . author || "(unknown)" ;
589+ if ( ! byAuthor . has ( a ) ) byAuthor . set ( a , [ ] ) ;
590+ byAuthor . get ( a ) . push ( r ) ;
591+ }
592+ const authors = [ ...byAuthor . entries ( ) ] . sort ( ( a , b ) => b [ 1 ] . length - a [ 1 ] . length ) ;
593+
594+ const total = rows . length ;
595+ const noun = total === 1 ? "merge" : "merges" ;
596+ const summary =
597+ `<div class="wk-summary">${ total } weekend ${ noun } · ${ authors . length } author${ authors . length === 1 ? "" : "s" } </div>` ;
598+
599+ const list = authors . map ( ( [ author , prs ] ) => {
600+ const prList = prs
601+ . sort ( ( a , b ) => ( a . merged < b . merged ? 1 : - 1 ) )
602+ . map ( p => `
603+ <div class="wk-pr">
604+ <span class="wk-tag wk-${ escapeHtml ( ( p . dow || "" ) . toLowerCase ( ) ) } ">${ escapeHtml ( p . dow || "" ) } </span>
605+ <a href="https://github.com/${ escapeHtml ( p . repo ) } /pull/${ encodeURIComponent ( p . pr ) } "
606+ target="_blank" rel="noopener noreferrer">#${ escapeHtml ( p . pr ) } </a>
607+ <span>${ escapeHtml ( p . title || "" ) } </span>
608+ <span class="wk-date">${ escapeHtml ( p . merged || "" ) } </span>
609+ </div>
610+ ` ) . join ( "" ) ;
611+ return `
612+ <details class="wk-author">
613+ <summary><span class="wk-author-name">${ escapeHtml ( author ) } </span><span class="wk-author-count">${ prs . length } </span></summary>
614+ ${ prList }
615+ </details>
616+ ` ;
617+ } ) . join ( "" ) ;
618+
619+ el . innerHTML = summary + list ;
620+ }
621+
527622// --------- date range helpers ---------
528623
529624/** Sorted unique week values across all metrics that have a `week` field. */
@@ -565,25 +660,36 @@ function weekToMondayDate(weekStr) {
565660 return target . toISOString ( ) . slice ( 0 , 10 ) ;
566661}
567662
663+ /** Convert a Monday-of-week date string ("YYYY-MM-DD") to the date string for
664+ * that week's Sunday (i.e. Monday + 6 days), keeping everything in UTC. */
665+ function weekEndDate ( mondayDate ) {
666+ const d = new Date ( mondayDate + "T00:00:00Z" ) ;
667+ d . setUTCDate ( d . getUTCDate ( ) + 6 ) ;
668+ return d . toISOString ( ) . slice ( 0 , 10 ) ;
669+ }
670+
671+ /** Resolve `[currentFrom, currentTo]` ISO weeks into a closed `[fromDate, toEnd]`
672+ * date span (Monday-of-from to Sunday-of-to). Returns null if no range is
673+ * active or the week strings can't be parsed. */
674+ function currentRangeDates ( ) {
675+ if ( ! currentFrom || ! currentTo ) return null ;
676+ const fromDate = weekToMondayDate ( currentFrom ) ;
677+ const toDate = weekToMondayDate ( currentTo ) ;
678+ if ( ! fromDate || ! toDate ) return null ;
679+ return { fromDate, toEnd : weekEndDate ( toDate ) } ;
680+ }
681+
568682/** Filter hotfix rows: keep each `hotfix` row in range AND its trailing
569683 * `preceded-by` rows (groups stay intact even if the prev row's date
570684 * is technically outside the window). */
571685function inRangeHotfixes ( rows ) {
572- if ( ! currentFrom || ! currentTo ) return rows ;
573- const fromDate = weekToMondayDate ( currentFrom ) ;
574- const toDate = weekToMondayDate ( currentTo ) ;
575- if ( ! fromDate || ! toDate ) return rows ;
576- // Add 6 days to toDate to include the whole "to" week.
577- const toEnd = ( ( ) => {
578- const d = new Date ( toDate + "T00:00:00Z" ) ;
579- d . setUTCDate ( d . getUTCDate ( ) + 6 ) ;
580- return d . toISOString ( ) . slice ( 0 , 10 ) ;
581- } ) ( ) ;
686+ const span = currentRangeDates ( ) ;
687+ if ( ! span ) return rows ;
582688 const out = [ ] ;
583689 let keepGroup = false ;
584690 for ( const r of rows ) {
585691 if ( r . relation === "hotfix" ) {
586- keepGroup = r . merged >= fromDate && r . merged <= toEnd ;
692+ keepGroup = r . merged >= span . fromDate && r . merged <= span . toEnd ;
587693 if ( keepGroup ) out . push ( r ) ;
588694 } else if ( keepGroup ) {
589695 out . push ( r ) ;
@@ -592,6 +698,15 @@ function inRangeHotfixes(rows) {
592698 return out ;
593699}
594700
701+ /** Filter weekend-merges rows by the date covered by [currentFrom, currentTo].
702+ * Each row carries `merged` (YYYY-MM-DD); we keep rows whose `merged` falls
703+ * inside the Monday-of-from to Sunday-of-to span. */
704+ function inRangeWeekendMerges ( rows ) {
705+ const span = currentRangeDates ( ) ;
706+ if ( ! span ) return rows ;
707+ return rows . filter ( r => r . merged && r . merged >= span . fromDate && r . merged <= span . toEnd ) ;
708+ }
709+
595710/** Compute [from, to] for a preset clicked on the current data.
596711 * Preset "all" → full data extent; numeric → last N weeks ending at latestWeek. */
597712function computePresetRange ( presetId ) {
0 commit comments