@@ -174,6 +174,9 @@ const batchState = {
174174 purityPanelHeader : null ,
175175 purityPanelList : null ,
176176 latestPurityReport : null ,
177+ purityNarrativeButton : null ,
178+ purityNarrativeContainer : null ,
179+ showPurityNarrative : false ,
177180} ;
178181
179182const RANDOMIZE_SEED_STORAGE_KEY = 'cfg.calendar.randomizeSeed' ;
@@ -4115,6 +4118,17 @@ function updateBatchPurityIndicator(summaryOverride) {
41154118 }
41164119}
41174120
4121+ function updatePurityNarrativeToggle ( hasReport ) {
4122+ const button = batchState . purityNarrativeButton ;
4123+ if ( ! button ) {
4124+ return ;
4125+ }
4126+ const isExpanded = Boolean ( batchState . showPurityNarrative && hasReport ) ;
4127+ button . textContent = isExpanded ? 'Collapse' : 'Elaborate' ;
4128+ button . setAttribute ( 'aria-expanded' , isExpanded ? 'true' : 'false' ) ;
4129+ button . disabled = ! hasReport ;
4130+ }
4131+
41184132function renderBatchPurityPanel ( reportOverride ) {
41194133 const panel = batchState . purityPanel ;
41204134 if ( ! panel ) {
@@ -4123,8 +4137,15 @@ function renderBatchPurityPanel(reportOverride) {
41234137
41244138 const report =
41254139 typeof reportOverride === 'undefined' ? batchState . latestPurityReport : reportOverride ;
4140+ const hasReport = Boolean ( report && report . human ) ;
4141+ updatePurityNarrativeToggle ( hasReport ) ;
41264142
4127- if ( ! report || ! report . human ) {
4143+ const narrativeContainer = batchState . purityNarrativeContainer ;
4144+ if ( ! hasReport ) {
4145+ if ( narrativeContainer ) {
4146+ narrativeContainer . hidden = true ;
4147+ narrativeContainer . innerHTML = '' ;
4148+ }
41284149 panel . hidden = true ;
41294150 return ;
41304151 }
@@ -4164,9 +4185,145 @@ function renderBatchPurityPanel(reportOverride) {
41644185 }
41654186 }
41664187
4188+ if ( narrativeContainer ) {
4189+ const shouldShowNarrative = Boolean ( batchState . showPurityNarrative && report ) ;
4190+ if ( ! shouldShowNarrative ) {
4191+ narrativeContainer . hidden = true ;
4192+ narrativeContainer . innerHTML = '' ;
4193+ } else {
4194+ const paragraphs = buildPurityNarrative ( report ) ;
4195+ if ( ! paragraphs . length ) {
4196+ narrativeContainer . hidden = true ;
4197+ narrativeContainer . innerHTML = '' ;
4198+ } else {
4199+ narrativeContainer . hidden = false ;
4200+ narrativeContainer . innerHTML = '' ;
4201+ const fragment = document . createDocumentFragment ( ) ;
4202+ paragraphs . forEach ( ( text ) => {
4203+ if ( typeof text !== 'string' || ! text . trim ( ) ) {
4204+ return ;
4205+ }
4206+ const paragraph = document . createElement ( 'p' ) ;
4207+ paragraph . textContent = text ;
4208+ fragment . append ( paragraph ) ;
4209+ } ) ;
4210+ narrativeContainer . append ( fragment ) ;
4211+ }
4212+ }
4213+ }
4214+
41674215 panel . hidden = false ;
41684216}
41694217
4218+ function formatPurityActivityLabel ( label ) {
4219+ if ( typeof label !== 'string' ) {
4220+ return 'This activity' ;
4221+ }
4222+ const cleaned = label . trim ( ) . replace ( / [ _ - ] + / g, ' ' ) ;
4223+ if ( ! cleaned ) {
4224+ return 'This activity' ;
4225+ }
4226+ return cleaned . replace ( / \b \w / g, ( char ) => char . toUpperCase ( ) ) ;
4227+ }
4228+
4229+ function buildPurityNarrative ( report ) {
4230+ if ( ! report ) {
4231+ return [ ] ;
4232+ }
4233+
4234+ const paragraphs = [ ] ;
4235+ const totalRuns = Number . isFinite ( report . batchSize ) ? report . batchSize : Number ( report . totalRuns ) || 0 ;
4236+ const pureRuns = Number . isFinite ( report . pureRuns ) ? report . pureRuns : 0 ;
4237+ const impureRuns = Number . isFinite ( report . impureRuns ) ? report . impureRuns : Math . max ( totalRuns - pureRuns , 0 ) ;
4238+ const totals = report . totals || { } ;
4239+ const avgRaw = Number . isFinite ( totals . avgRaw ) ? totals . avgRaw : 0 ;
4240+ const avgShare = Number . isFinite ( totals . avgShare ) ? totals . avgShare : 0 ;
4241+ const avgSequence = Number . isFinite ( totals . avgSequence ) ? totals . avgSequence : 0 ;
4242+ const shareDriftMinutes = Number . isFinite ( totals . avgShareDriftMinutes )
4243+ ? totals . avgShareDriftMinutes
4244+ : avgShare - avgRaw ;
4245+ const shareDriftPercent = Number . isFinite ( totals . avgShareDriftPercent )
4246+ ? totals . avgShareDriftPercent
4247+ : avgRaw === 0
4248+ ? 0
4249+ : shareDriftMinutes / avgRaw ;
4250+ const driftMagnitudeText = formatPurityMinutes ( Math . abs ( shareDriftMinutes ) ) ;
4251+ const driftPercentText = formatPurityPercent ( shareDriftPercent ) ;
4252+ const severity = Math . abs ( shareDriftPercent ) < 0.05
4253+ ? 'very close'
4254+ : Math . abs ( shareDriftPercent ) < 0.15
4255+ ? 'moderately off'
4256+ : 'significantly misaligned' ;
4257+
4258+ if ( totalRuns <= 0 ) {
4259+ paragraphs . push ( 'No batch runs have been analyzed yet, so the purity checker does not have a verdict.' ) ;
4260+ } else {
4261+ const statusWord = impureRuns > 0 ? 'failing' : 'passing' ;
4262+ let driftSentence = 'matching the raw totals almost exactly.' ;
4263+ if ( shareDriftMinutes !== 0 ) {
4264+ const direction = shareDriftMinutes < 0 ? 'undercounting' : 'overstating' ;
4265+ const percentSuffix = driftPercentText === 'n/a' ? '' : ` (${ driftPercentText } )` ;
4266+ driftSentence = `${ direction } about ${ driftMagnitudeText } minutes per run${ percentSuffix } .` ;
4267+ }
4268+ paragraphs . push (
4269+ `This batch of ${ totalRuns } runs is ${ statusWord } the purity check, with ${ pureRuns } marked pure and ${ impureRuns } flagged impure. On average, the Share view is ${ severity } versus the raw schedules, ${ driftSentence } `
4270+ ) ;
4271+ }
4272+
4273+ if ( avgRaw > 0 || avgShare > 0 || avgSequence > 0 ) {
4274+ const avgRawText = formatPurityMinutes ( avgRaw ) ;
4275+ const avgShareText = formatPurityMinutes ( avgShare ) ;
4276+ const avgSequenceText = formatPurityMinutes ( avgSequence ) ;
4277+ const gapText = formatPurityMinutes ( Math . abs ( avgShare - avgRaw ) ) ;
4278+ paragraphs . push (
4279+ `Across the batch, raw and Sequence timelines stay aligned at roughly ${ avgRawText } and ${ avgSequenceText } minutes per run, while Share only captures ${ avgShareText } . That shortfall of about ${ gapText } minutes per run is what the diagnostics are highlighting.`
4280+ ) ;
4281+ }
4282+
4283+ const activities = Array . isArray ( report . activities ) ? report . activities : [ ] ;
4284+ const rankedActivities = activities
4285+ . filter ( ( activity ) => {
4286+ const driftValue = Number ( activity ?. driftMinutes ) ;
4287+ return Number . isFinite ( driftValue ) && Math . abs ( driftValue ) >= 1 ;
4288+ } )
4289+ . sort ( ( a , b ) => Math . abs ( ( b ?. driftMinutes ) || 0 ) - Math . abs ( ( a ?. driftMinutes ) || 0 ) )
4290+ . slice ( 0 , 2 ) ;
4291+ if ( rankedActivities . length ) {
4292+ const activitySentences = rankedActivities . map ( ( activity ) => {
4293+ const driftMinutes = Number ( activity ?. driftMinutes ) || 0 ;
4294+ const descriptor = driftMinutes < 0 ? 'undercounted' : 'overcounted' ;
4295+ const deltaWord = driftMinutes < 0 ? 'missing' : 'adding' ;
4296+ const driftText = formatPurityMinutes ( Math . abs ( driftMinutes ) ) ;
4297+ const percentText = formatPurityPercent ( activity ?. driftPercent ) ;
4298+ const percentSuffix = percentText === 'n/a' ? '' : ` (${ percentText } )` ;
4299+ const rawText = formatPurityMinutes ( activity ?. avgRaw ) ;
4300+ const shareText = formatPurityMinutes ( activity ?. avgShare ) ;
4301+ const label = formatPurityActivityLabel ( activity ?. id ) ;
4302+ return `${ label } is ${ descriptor } : raw schedules average about ${ rawText } minutes per run while Share shows ${ shareText } , ${ deltaWord } roughly ${ driftText } minutes${ percentSuffix } .` ;
4303+ } ) ;
4304+ paragraphs . push ( `Distortion concentrates in a few activities. ${ activitySentences . join ( ' ' ) } ` ) ;
4305+ }
4306+
4307+ const worstRuns = Array . isArray ( report . worstRuns ) ? report . worstRuns : [ ] ;
4308+ const notableRuns = worstRuns
4309+ . filter ( ( run ) => Number . isFinite ( run ?. driftMinutes ) )
4310+ . slice ( 0 , 3 ) ;
4311+ if ( notableRuns . length ) {
4312+ const runText = notableRuns
4313+ . map ( ( run ) => {
4314+ const label = Number . isFinite ( run ?. runIndex ) && run . runIndex > 0 ? `#${ run . runIndex } ` : 'one run' ;
4315+ const magnitude = formatPurityMinutes ( Math . abs ( Number ( run ?. driftMinutes ) || 0 ) ) ;
4316+ return `${ label } (~${ magnitude } min)` ;
4317+ } )
4318+ . join ( ', ' ) ;
4319+ paragraphs . push (
4320+ `The discrepancy is not uniform: runs ${ runText } show the steepest gaps, so their Share visualisations should be treated as sketches rather than precise ledgers.`
4321+ ) ;
4322+ }
4323+
4324+ return paragraphs . slice ( 0 , 4 ) ;
4325+ }
4326+
41704327function logBatchPurityReport ( summary ) {
41714328 if ( ! summary ) {
41724329 return ;
@@ -4879,6 +5036,18 @@ function hydrateBatchPanel() {
48795036 batchState . purityPanelStatus = panel . querySelector ( '[data-batch-purity-status]' ) ;
48805037 batchState . purityPanelHeader = panel . querySelector ( '[data-batch-purity-header]' ) ;
48815038 batchState . purityPanelList = panel . querySelector ( '[data-batch-purity-list]' ) ;
5039+ batchState . purityNarrativeButton = panel . querySelector ( '[data-purity-narrative-toggle]' ) ;
5040+ batchState . purityNarrativeContainer = panel . querySelector ( '[data-batch-purity-narrative]' ) ;
5041+ if ( batchState . purityNarrativeButton instanceof HTMLButtonElement ) {
5042+ batchState . purityNarrativeButton . type = 'button' ;
5043+ batchState . purityNarrativeButton . addEventListener ( 'click' , ( ) => {
5044+ if ( ! batchState . latestPurityReport ) {
5045+ return ;
5046+ }
5047+ batchState . showPurityNarrative = ! batchState . showPurityNarrative ;
5048+ renderBatchPurityPanel ( batchState . latestPurityReport ) ;
5049+ } ) ;
5050+ }
48825051
48835052 batchState . sizeButtons = new Map ( ) ;
48845053 const sizeButtons = panel . querySelectorAll ( '[data-batch-size]' ) ;
0 commit comments