Jump to content

User:Gauri Guptaa/pageHealthDashboard.js

From Wikimedia Commons, the free media repository
Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
/**
 * Displays an inline page health dashboard on Wikipedia (and other
 * Wikimedia wikis) article pages, showing colour-graded metrics that
 * would otherwise require navigating to the separate action=info page
 * or running external tools.
 *
 * Redundancy check:
 *   MediaWiki's built-in action=info page shows page size, edit count,
 *   watchers, and a link to pageview statistics β€” but only on a *separate*
 *   page, never inline on the article itself. No native feature shows
 *   per-section citation density, maintenance-tag count, or a composite
 *   health score inline while reading. This script fills that gap.
 *
 * Metrics shown (all fetched from the same-domain MediaWiki API):
 *   πŸ“„ Article size      β€” bytes, colour-graded green/amber/red
 *   πŸ“ Word count        β€” estimated from prose text nodes
 *   πŸ”— Wikilinks         β€” internal link count
 *   πŸ“š References        β€” total <ref> citations on the page
 *   ⚠️  Maintenance tags  β€” count of {{stub}}, {{cleanup}}, {{unreferenced}}
 *                          and similar maintenance templates (prop=templates,
 *                          filtered by Template:Ambox members)
 *   πŸ“Š Sections          β€” number of headings
 *   πŸ‘οΈ  Pageviews (30d)  β€” via Wikimedia Pageviews REST API
 *                          (wikimedia.org β€” allowed by Wikimedia CSP)
 *   πŸ•’ Last edited       β€” human-readable relative timestamp
 *
 * A composite "health score" (0–100) is derived from the above and shown
 * as a colour-coded badge: green (good), amber (needs work), red (poor).
 *
 * The dashboard is injected as a collapsible panel at the top of the
 * article content area. It can be dismissed and will not reappear until
 * the page is reloaded.
 *
 * APIs used:
 *   mw.Api β€” action=query, prop=info|revisions|templates|categories
 *   Wikimedia REST API β€” /api/rest_v1/metrics/pageviews (same *.wikimedia.org
 *   origin β€” permitted by Wikimedia CSP)
 *   mw.util.addCSS, mw.config, mw.hook('wikipage.content')
 *
 * <nowiki>
 * Where to save:
 *   commons.wikimedia.org/wiki/User:Gauri_Guptaa/pageHealthDashboard.js
 *
 * Add to each wiki's common.js:
 *   mw.loader.load('//commons.wikimedia.org/w/index.php?title=User:Gauri_Guptaa/pageHealthDashboard.js&action=raw&ctype=text/javascript');
 *
 * Works on: Wikipedia (all languages), Commons, Wikidata β€” NS 0 only.
 * </nowiki>
 *
 * @author   [[User:Gauri_Guptaa]]
 * @source   [[User:Gauri_Guptaa/pageHealthDashboard.js]]
 * @license  CC0-1.0
 */
/* global mw */
( function () {
	'use strict';

	if ( mw.config.get( 'wgNamespaceNumber' ) !== 0 ) {
		return;
	}
	if ( mw.config.get( 'wgAction' ) !== 'view' ) {
		return;
	}

	var PANEL_ID = 'phd-panel';

	// ── Maintenance template names that signal quality issues ─────────────────
	// These are Template: page names (without "Template:" prefix).
	// Filtered from prop=templates response.
	var MAINT_RE = /^(stub|cleanup|unreferenced|refimprove|expand|wikify|orphan|advert|coi|pov|original.research|notability|merge|split|update|outdated|dead.end|citation.needed|medref|primarysources|improve)/i;

	// ── Styles ────────────────────────────────────────────────────────────────

	mw.util.addCSS(
		'#' + PANEL_ID + '{' +
			'border:1px solid #a2a9b1;border-radius:6px;' +
			'margin-bottom:1em;font-size:13px;clear:both;' +
			'background:var(--background-color-base,#fff);' +
			'overflow:hidden;' +
		'}' +
		'#phd-head{' +
			'display:flex;align-items:center;justify-content:space-between;' +
			'padding:8px 14px;cursor:pointer;user-select:none;' +
			'background:var(--background-color-interactive,#f8f9fa);' +
			'border-bottom:1px solid #a2a9b1;font-weight:700;' +
		'}' +
		'#phd-head.phd-collapsed{border-bottom:0}' +
		'#phd-toggle{font-size:11px;font-weight:400;color:#54595d}' +
		'#phd-body{padding:12px 14px}' +
		'#phd-body.phd-hidden{display:none}' +
		'#phd-score-row{' +
			'display:flex;align-items:center;gap:14px;margin-bottom:12px;' +
			'padding-bottom:10px;border-bottom:1px solid #eaecf0;' +
		'}' +
		'#phd-score-badge{' +
			'width:52px;height:52px;border-radius:50%;' +
			'display:flex;align-items:center;justify-content:center;' +
			'font-size:18px;font-weight:700;color:#fff;flex-shrink:0;' +
		'}' +
		'#phd-score-label{font-size:12px;color:#54595d;margin-top:2px}' +
		'#phd-metrics{' +
			'display:grid;' +
			'grid-template-columns:repeat(auto-fill,minmax(130px,1fr));' +
			'gap:8px;' +
		'}' +
		'.phd-metric{' +
			'background:var(--background-color-interactive,#f8f9fa);' +
			'border:1px solid #eaecf0;border-radius:6px;' +
			'padding:7px 10px;' +
		'}' +
		'.phd-metric-val{' +
			'font-size:16px;font-weight:700;color:#202122;line-height:1;' +
		'}' +
		'.phd-metric-lbl{font-size:11px;color:#54595d;margin-top:3px}' +
		'.phd-metric-bar{' +
			'height:3px;border-radius:2px;margin-top:5px;' +
			'background:#eaecf0;overflow:hidden;' +
		'}' +
		'.phd-metric-fill{height:100%;border-radius:2px;transition:width .4s}' +
		'.phd-green{color:#14803c}.phd-amber{color:#a66800}.phd-red{color:#c00}' +
		'.phd-fill-green{background:#14803c}' +
		'.phd-fill-amber{background:#a66800}' +
		'.phd-fill-red{background:#c00}' +
		'#phd-footer{' +
			'margin-top:10px;padding-top:8px;border-top:1px solid #eaecf0;' +
			'font-size:11px;color:#72777d;display:flex;' +
			'justify-content:space-between;align-items:center;' +
		'}' +
		'#phd-footer a{color:#3366cc;font-size:11px}' +
		'#phd-close-btn{' +
			'cursor:pointer;color:#72777d;background:none;border:0;' +
			'padding:0;font-size:15px;line-height:1;margin-left:8px;' +
		'}'
	);

	// ── Grading helpers ───────────────────────────────────────────────────────

	/**
	 * Return a CSS class suffix based on a 0–1 ratio and thresholds.
	 * ratio β‰₯ goodThreshold β†’ green, β‰₯ warnThreshold β†’ amber, else red.
	 *
	 * @param  {number} ratio
	 * @param  {number} goodThreshold
	 * @param  {number} warnThreshold
	 * @return {string}  'green' | 'amber' | 'red'
	 */
	function grade( ratio, goodThreshold, warnThreshold ) {
		if ( ratio >= goodThreshold ) {
			return 'green';
		}
		if ( ratio >= warnThreshold ) {
			return 'amber';
		}
		return 'red';
	}

	function relTime( iso ) {
		var diff = Date.now() - new Date( iso ).getTime();
		var mins = Math.floor( diff / 60000 );
		if ( mins < 60 ) {
			return mins + ' min ago';
		}
		var hrs = Math.floor( mins / 60 );
		if ( hrs < 24 ) {
			return hrs + ' hr ago';
		}
		var days = Math.floor( hrs / 24 );
		if ( days < 30 ) {
			return days + ' day' + ( days !== 1 ? 's' : '' ) + ' ago';
		}
		var mos = Math.floor( days / 30 );
		return mos + ' month' + ( mos !== 1 ? 's' : '' ) + ' ago';
	}

	// ── DOM metric helpers ────────────────────────────────────────────────────

	/** Count words in the prose text of the article. */
	function countWords( parser ) {
		var text = '';
		parser.querySelectorAll( 'p' ).forEach( function ( p ) {
			if ( !p.closest( '.infobox,.navbox,.reflist,table' ) ) {
				text += ' ' + p.textContent;
			}
		} );
		return text.trim().split( /\s+/ ).filter( Boolean ).length;
	}

	/** Count inline <ref> citations rendered as footnote links. */
	function countRefs( parser ) {
		return parser.querySelectorAll(
			'sup.reference, .mw-ref, ol.references > li'
		).length;
	}

	/** Count internal wikilinks. */
	function countLinks( parser ) {
		return parser.querySelectorAll(
			'#mw-content-text a[href^="/wiki/"]:not(.image):not(.new)'
		).length;
	}

	/** Count section headings (h2 + h3). */
	function countSections( parser ) {
		return parser.querySelectorAll( 'h2,h3' ).length;
	}

	// ── Composite health score ────────────────────────────────────────────────

	/**
	 * Compute a 0–100 health score from the gathered metrics.
	 * Weights are intentionally simple and readable.
	 *
	 * @param  {Object} m  metrics object
	 * @return {number}
	 */
	function healthScore( m ) {
		var score = 0;
		// References (0–30 points): >20 refs = full marks
		score += Math.min( 30, ( m.refs / 20 ) * 30 );
		// Article size (0–20 points): >20 kB = full marks
		score += Math.min( 20, ( m.bytes / 20000 ) * 20 );
		// Word count (0–20 points): >2000 words = full marks
		score += Math.min( 20, ( m.words / 2000 ) * 20 );
		// Wikilinks (0–15 points): >30 links = full marks
		score += Math.min( 15, ( m.links / 30 ) * 15 );
		// Maintenance tags penalty (0 to -15 points)
		score -= Math.min( 15, m.maintTags * 5 );

		return Math.max( 0, Math.min( 100, Math.round( score ) ) );
	}

	function scoreColor( s ) {
		if ( s >= 70 ) {
			return '#14803c';
		}
		if ( s >= 40 ) {
			return '#a66800';
		}
		return '#c00';
	}

	function scoreLabel( s ) {
		if ( s >= 70 ) {
			return 'Good';
		}
		if ( s >= 40 ) {
			return 'Needs work';
		}
		return 'Poor';
	}

	// ── Build metric card ─────────────────────────────────────────────────────

	function metricCard( icon, label, value, fillPct, gradeStr ) {
		var $card = $( '<div class="phd-metric">' );
		var $val  = $( '<div class="phd-metric-val">' )
			.text( icon + ' ' + value );
		if ( gradeStr ) {
			$val.addClass( 'phd-' + gradeStr );
		}
		$card.append(
			$val,
			$( '<div class="phd-metric-lbl">' ).text( label )
		);
		if ( fillPct !== null ) {
			$card.append(
				$( '<div class="phd-metric-bar">' ).append(
					$( '<div class="phd-metric-fill phd-fill-' + ( gradeStr || 'green' ) + '">' )
						.css( 'width', Math.min( 100, fillPct ) + '%' )
				)
			);
		}
		return $card;
	}

	// ── Render dashboard ──────────────────────────────────────────────────────

	function render( parser, m ) {
		var score  = healthScore( m );
		var sColor = scoreColor( score );
		var sLabel = scoreLabel( score );

		var $panel = $( '#' + PANEL_ID );
		var $body  = $panel.find( '#phd-body' );
		$body.empty();

		// Score row
		var $scoreRow = $( '<div id="phd-score-row">' );
		$( '<div id="phd-score-badge">' )
			.css( 'background', sColor )
			.text( score )
			.appendTo( $scoreRow );
		$( '<div>' ).append(
			$( '<div>' ).css( { fontWeight: '700', fontSize: '14px' } )
				.text( sLabel ),
			$( '<div id="phd-score-label">' )
				.text( 'Page health score Β· based on size, refs, links, tags' )
		).appendTo( $scoreRow );
		$body.append( $scoreRow );

		// Metrics grid
		var $grid = $( '<div id="phd-metrics">' );

		// Size
		var kb        = ( m.bytes / 1024 ).toFixed( 1 );
		var sizeGrade = grade( m.bytes / 30000, 0.67, 0.33 );
		$grid.append( metricCard( 'πŸ“„', 'Article size',
			kb + ' kB', m.bytes / 300, sizeGrade ) );

		// Words
		var wGrade = grade( m.words / 2000, 1, 0.5 );
		$grid.append( metricCard( 'πŸ“', 'Word count',
			m.words.toLocaleString(), m.words / 20, wGrade ) );

		// References
		var rGrade = grade( m.refs / 20, 1, 0.5 );
		$grid.append( metricCard( 'πŸ“š', 'References',
			m.refs, m.refs * 5, rGrade ) );

		// Wikilinks
		var lGrade = grade( m.links / 30, 1, 0.5 );
		$grid.append( metricCard( 'πŸ”—', 'Wikilinks',
			m.links, m.links * 3, lGrade ) );

		// Sections
		$grid.append( metricCard( 'πŸ“Š', 'Sections', m.sections, null, null ) );

		// Maintenance tags
		var tGrade = m.maintTags === 0 ? 'green' :
		             m.maintTags <= 2  ? 'amber' : 'red';
		$grid.append( metricCard( '⚠️', 'Maint. tags',
			m.maintTags === 0 ? 'None' : m.maintTags,
			null, tGrade ) );

		// Pageviews
		if ( m.pageviews !== null ) {
			$grid.append( metricCard( 'πŸ‘οΈ', 'Views (30d)',
				m.pageviews.toLocaleString(), null, null ) );
		}

		// Last edited
		if ( m.lastEdit ) {
			$grid.append( metricCard( 'πŸ•’', 'Last edited',
				relTime( m.lastEdit ), null, null ) );
		}

		$body.append( $grid );

		// Footer with link to full action=info page
		var infoUrl = mw.util.getUrl( mw.config.get( 'wgPageName' ),
			{ action: 'info' } );
		$body.append(
			$( '<div id="phd-footer">' ).append(
				$( '<span>' ).text( 'Metrics from MediaWiki API Β· DOM analysis' ),
				$( '<a>' ).attr( { href: infoUrl } ).text( 'Full page info β†’' )
			)
		);
	}

	// ── Fetch pageviews from Wikimedia REST API ───────────────────────────────
	// wikimedia.org is in Wikimedia's own CSP allowlist.

	function fetchPageviews( title, project ) {
		var end   = new Date();
		var start = new Date( end );
		start.setDate( start.getDate() - 30 );

		function pad( n ) {
			return String( n ).padStart( 2, '0' );
		}
		function fmt( d ) {
			return d.getFullYear() +
				pad( d.getMonth() + 1 ) +
				pad( d.getDate() ) + '00';
		}

		var url = 'https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/' +
			encodeURIComponent( project ) + '/all-access/user/' +
			encodeURIComponent( title.replace( / /g, '_' ) ) +
			'/daily/' + fmt( start ) + '/' + fmt( end );

		var d = $.Deferred();
		$.ajax( {
			url: url,
			dataType: 'json',
			timeout: 8000
		} ).done( function ( data ) {
			var total = ( data.items || [] ).reduce( function ( s, i ) {
				return s + ( i.views || 0 );
			}, 0 );
			d.resolve( total );
		} ).fail( function () {
			d.resolve( null );
		} );
		return d.promise();
	}

	// ── Main process ──────────────────────────────────────────────────────────

	function process( $content ) {
		var parser = $content[ 0 ];
		if ( parser.dataset.phdDone ) {
			return;
		}
		parser.dataset.phdDone = '1';

		var title   = mw.config.get( 'wgPageName' );
		var host    = window.location.hostname;  // e.g. en.wikipedia.org
		// Wikimedia pageviews project string: en.wikipedia, fr.wikipedia, etc.
		var project = host.replace( /^www\./, '' );

		// Build panel skeleton immediately.
		var $panel = $( '<div id="' + PANEL_ID + '">' );
		var $head  = $( '<div id="phd-head">' ).append(
			$( '<span>' ).html( 'πŸ“Š Page health dashboard' ),
			$( '<div>' ).append(
				$( '<span id="phd-toggle">' ).text( 'β–² collapse' ),
				$( '<button id="phd-close-btn" type="button">' )
					.text( 'βœ•' ).attr( 'title', 'Dismiss' )
					.on( 'click', function ( e ) {
						e.stopPropagation();
						$panel.remove();
					} )
			)
		).on( 'click', function () {
			var c = $head.hasClass( 'phd-collapsed' );
			$head.toggleClass( 'phd-collapsed', !c );
			$body.toggleClass( 'phd-hidden', !c );
			$( '#phd-toggle' ).text( c ? 'β–² collapse' : 'β–Ό expand' );
		} );

		var $body = $( '<div id="phd-body">' )
			.html( '<span style="color:#72777d;font-style:italic">Loading metrics…</span>' );

		$panel.append( $head, $body );
		$( parser ).prepend( $panel );

		// Collect DOM metrics (synchronous β€” no API needed).
		var domMetrics = {
			words:    countWords( parser ),
			refs:     countRefs( parser ),
			links:    countLinks( parser ),
			sections: countSections( parser )
		};

		// API call: page size, last-edit timestamp, maintenance templates.
		var apiCall = new mw.Api().get( {
			action:     'query',
			titles:     title,
			prop:       'info|revisions|templates',
			inprop:     'url',
			rvprop:     'timestamp',
			rvlimit:    1,
			tllimit:    'max',
			tlnamespace: 10,  // NS_TEMPLATE
			format:     'json'
		} );

		$.when( apiCall, fetchPageviews( title, project ) )
			.done( function ( apiData, pv ) {
				var pages  = apiData[ 0 ].query.pages;
				var page   = pages[ Object.keys( pages )[ 0 ] ];

				var bytes    = page.length || 0;
				var lastEdit = ( page.revisions && page.revisions[ 0 ] ) ?
					page.revisions[ 0 ].timestamp : null;

				// Filter maintenance templates.
				var maintTags = ( page.templates || [] ).filter( function ( t ) {
					var name = t.title.replace( /^Template:/, '' );
					return MAINT_RE.test( name );
				} ).length;

				render( parser, {
					bytes:     bytes,
					words:     domMetrics.words,
					refs:      domMetrics.refs,
					links:     domMetrics.links,
					sections:  domMetrics.sections,
					maintTags: maintTags,
					pageviews: pv,
					lastEdit:  lastEdit
				} );
			} )
			.fail( function () {
				$body.html( '<span style="color:#c00">Could not load metrics.</span>' );
			} );
	}

	mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] ).then( function () {
		mw.hook( 'wikipage.content' ).add( process );
	} );

}() );