Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* =====================================================================
 * Bharatpedia Gadget: UserStats
 * Adds a "User stats" link to the Tools menu. Clicking it opens a dialog
 * where any username can be looked up: registration date, total edits,
 * mainspace edits, namespace breakdown, monthly activity, edit tags and
 * recent edits. Data comes from the wiki's own API (same origin).
 * ===================================================================== */
( function () {
	'use strict';

	var api = new mw.Api(),
		nsCache = null,
		DIALOG_ID = 'bp-userstats-dialog';

	function esc( s ) {
		return String( s ).replace( /[&<>"']/g, function ( c ) {
			return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ c ];
		} );
	}

	function fmtDate( iso ) {
		if ( !iso ) return '—';
		var d = new Date( iso );
		var months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
		return d.getUTCDate() + ' ' + months[ d.getUTCMonth() ] + ' ' + d.getUTCFullYear();
	}

	function getNamespaces() {
		if ( nsCache ) {
			return $.Deferred().resolve( nsCache ).promise();
		}
		return api.get( {
			action: 'query',
			meta: 'siteinfo',
			siprop: 'namespaces',
			formatversion: 2
		} ).then( function ( d ) {
			nsCache = {};
			$.each( d.query.namespaces, function ( id, ns ) {
				nsCache[ id ] = ns.canonical || ns.name || ( 'NS ' + id );
			} );
			return nsCache;
		} );
	}

	function openDialog() {
		closeDialog();
		var $overlay = $( '<div>' ).attr( 'id', DIALOG_ID ).css( {
			position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
			background: 'rgba(0,0,0,0.55)', 'z-index': 10000,
			display: 'flex', 'align-items': 'flex-start', 'justify-content': 'center',
			padding: '40px 12px', 'box-sizing': 'border-box', overflow: 'auto'
		} );
		var $panel = $( '<div>' ).css( {
			background: '#fff', color: '#202122', 'border-radius': '8px',
			width: '100%', 'max-width': '640px', padding: '20px 22px',
			'box-shadow': '0 8px 40px rgba(0,0,0,0.35)', 'box-sizing': 'border-box'
		} );
		var $head = $( '<div>' ).css( { display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '12px' } );
		$head.append( $( '<b>' ).text( 'User statistics' ).css( 'font-size', '18px' ) );
		var $close = $( '<button>' ).text( '✕' ).css( {
			border: '1px solid #a2a9b1', background: '#f8f9fa', 'border-radius': '4px',
			cursor: 'pointer', padding: '2px 10px', 'font-size': '14px'
		} ).on( 'click', closeDialog );
		$head.append( $close );
		$panel.append( $head );

		var $form = $( '<div>' ).css( { display: 'flex', gap: '8px', 'margin-bottom': '14px' } );
		var $input = $( '<input>' ).attr( { type: 'text', placeholder: 'Username', id: 'bp-us-name' } ).css( {
			flex: 1, padding: '8px 10px', 'font-size': '14px',
			border: '1px solid #a2a9b1', 'border-radius': '4px'
		} );
		var $btn = $( '<button>' ).text( 'Check' ).css( {
			padding: '8px 18px', 'font-size': '14px', cursor: 'pointer',
			background: '#36c', color: '#fff', border: 'none', 'border-radius': '4px'
		} );
		$form.append( $input, $btn );
		$panel.append( $form );

		var $out = $( '<div>' ).attr( 'id', 'bp-us-out' ).css( 'font-size', '14px' );
		$panel.append( $out );
		$overlay.append( $panel );
		$( document.body ).append( $overlay );

		$overlay.on( 'click', function ( e ) {
			if ( e.target === $overlay[ 0 ] ) closeDialog();
		} );
		$( document ).on( 'keydown.bpuserstats', function ( e ) {
			if ( e.key === 'Escape' ) closeDialog();
		} );

		function run() {
			var name = $.trim( $input.val() );
			if ( name ) lookup( name, $out, $btn );
		}
		$btn.on( 'click', run );
		$input.on( 'keydown', function ( e ) { if ( e.key === 'Enter' ) run(); } );
		$input.trigger( 'focus' );
	}

	function closeDialog() {
		$( '#' + DIALOG_ID ).remove();
		$( document ).off( 'keydown.bpuserstats' );
	}

	function lookup( username, $out, $btn ) {
		$btn.prop( 'disabled', true ).text( 'Loading…' );
		$out.html( '<p style="color:#72777d">Fetching data…</p>' );

		var userInfo;
		api.get( {
			action: 'query',
			list: 'users',
			ususers: username,
			usprop: 'registration|editcount|groups',
			formatversion: 2
		} ).then( function ( d ) {
			var u = d.query.users[ 0 ];
			if ( u.missing || u.invalid ) {
				throw new Error( 'User "' + username + '" not found.' );
			}
			userInfo = u;
			return fetchContribs( username );
		} ).then( function ( contribs ) {
			return getNamespaces().then( function ( nsNames ) {
				render( username, userInfo, contribs, nsNames, $out );
			} );
		} ).fail( function ( err ) {
			var msg = ( err && err.message ) || 'Something went wrong.';
			$out.html( '<p style="color:#d33">' + esc( msg ) + '</p>' );
		} ).always( function () {
			$btn.prop( 'disabled', false ).text( 'Check' );
		} );
	}

	function fetchContribs( username ) {
		var all = [],
			params = {
				action: 'query',
				list: 'usercontribs',
				ucuser: username,
				uclimit: 500,
				ucprop: 'ids|title|timestamp|sizediff|flags|tags',
				formatversion: 2
			};
		function oneBatch() {
			return api.get( params ).then( function ( d ) {
				all = all.concat( d.query.usercontribs );
				if ( d[ 'continue' ] && all.length < 5000 ) {
					params.uccontinue = d[ 'continue' ].uccontinue;
					return oneBatch();
				}
				return all;
			} );
		}
		return oneBatch();
	}

	function render( username, u, contribs, nsNames, $out ) {
		var total = u.editcount,
			nsCounts = {},
			mainspace = 0,
			minor = 0,
			months = {},
			pages = {},
			tags = {},
			i, c, key;

		for ( i = 0; i < contribs.length; i++ ) {
			c = contribs[ i ];
			nsCounts[ c.ns ] = ( nsCounts[ c.ns ] || 0 ) + 1;
			if ( c.ns === 0 ) mainspace++;
			if ( c.minor ) minor++;
			key = c.timestamp.slice( 0, 7 );
			months[ key ] = ( months[ key ] || 0 ) + 1;
			pages[ c.title ] = ( pages[ c.title ] || 0 ) + 1;
			( c.tags || [] ).forEach( function ( t ) {
				tags[ t ] = ( tags[ t ] || 0 ) + 1;
			} );
		}

		var analysed = contribs.length,
			capped = total > analysed,
			mainPct = total ? Math.round( mainspace / total * 100 ) : 0;

		var html = '';
		html += '<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px">';
		html += statCard( 'Registered', fmtDate( u.registration ) );
		html += statCard( 'Total edits', total.toLocaleString( 'en-IN' ) );
		html += statCard( 'Mainspace edits', mainspace.toLocaleString( 'en-IN' ) + ' (' + mainPct + '%)' );
		html += statCard( 'Minor edits', minor.toLocaleString( 'en-IN' ) );
		html += '</div>';

		if ( u.groups && u.groups.length ) {
			var g = u.groups.filter( function ( x ) { return x !== '*'; } );
			if ( g.length ) {
				html += '<p style="margin:0 0 14px"><b>Groups:</b> ' + esc( g.join( ', ' ) ) + '</p>';
			}
		}
		if ( capped ) {
			html += '<p style="color:#72777d;margin:0 0 14px">Showing analysis of the latest ' +
				analysed.toLocaleString( 'en-IN' ) + ' edits (API cap).</p>';
		}

		var nsRows = Object.keys( nsCounts ).map( Number ).sort( function ( a, b ) {
			return nsCounts[ b ] - nsCounts[ a ];
		} );
		html += '<h4 style="margin:14px 0 6px">Edits by namespace</h4>';
		html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
		nsRows.forEach( function ( id ) {
			var n = nsCounts[ id ], pct = Math.round( n / analysed * 100 );
			html += '<tr style="border-bottom:1px solid #eaecf0">' +
				'<td style="padding:4px 6px">' + esc( nsNames[ id ] || ( 'NS ' + id ) ) + '</td>' +
				'<td style="padding:4px 6px"><div style="background:#eaecf0;border-radius:3px">' +
				'<div style="background:#36c;height:8px;border-radius:3px;width:' + pct + '%"></div></div></td>' +
				'<td style="padding:4px 6px;text-align:right;white-space:nowrap">' + n.toLocaleString( 'en-IN' ) + '</td></tr>';
		} );
		html += '</table>';

		var mKeys = Object.keys( months ).sort().slice( -12 );
		if ( mKeys.length ) {
			var max = Math.max.apply( null, mKeys.map( function ( k ) { return months[ k ]; } ) );
			html += '<h4 style="margin:14px 0 6px">Monthly activity</h4>';
			html += '<div style="display:flex;align-items:flex-end;gap:4px;height:90px">';
			mKeys.forEach( function ( k ) {
				var h = Math.max( 4, Math.round( months[ k ] / max * 80 ) );
				html += '<div title="' + esc( k ) + ': ' + months[ k ] + ' edits" style="flex:1;background:#36c;border-radius:3px 3px 0 0;height:' + h + 'px"></div>';
			} );
			html += '</div>';
			html += '<div style="display:flex;gap:4px;font-size:10px;color:#72777d">';
			mKeys.forEach( function ( k ) {
				html += '<div style="flex:1;text-align:center;overflow:hidden">' + esc( k.slice( 2 ) ) + '</div>';
			} );
			html += '</div>';
		}

		var tagKeys = Object.keys( tags ).sort( function ( a, b ) { return tags[ b ] - tags[ a ]; } ).slice( 0, 8 );
		if ( tagKeys.length ) {
			html += '<h4 style="margin:14px 0 6px">Edit tags</h4><p style="margin:0;font-size:13px">' +
				tagKeys.map( function ( t ) { return esc( t ) + ' (' + tags[ t ] + ')'; } ).join( ' · ' ) + '</p>';
		}

		var topPages = Object.keys( pages ).sort( function ( a, b ) { return pages[ b ] - pages[ a ]; } ).slice( 0, 8 );
		if ( topPages.length ) {
			html += '<h4 style="margin:14px 0 6px">Most edited pages</h4><ul style="margin:0;padding-left:20px;font-size:13px">';
			topPages.forEach( function ( t ) {
				html += '<li>' + esc( t ) + ' <span style="color:#72777d">(' + pages[ t ] + ')</span></li>';
			} );
			html += '</ul>';
		}

		html += '<h4 style="margin:14px 0 6px">Recent edits</h4><ul style="margin:0;padding-left:20px;font-size:13px">';
		contribs.slice( 0, 10 ).forEach( function ( ed ) {
			html += '<li>' + esc( ed.title ) +
				' <span style="color:#72777d">' + fmtDate( ed.timestamp ) + '</span></li>';
		} );
		html += '</ul>';

		html += '<p style="margin:14px 0 0;font-size:12px;color:#72777d">Profile: <a href="' +
			mw.util.getUrl( 'Special:Contributions/' + username ) + '">contributions</a> · ' +
			'<a href="' + mw.util.getUrl( 'User:' + username ) + '">user page</a></p>';

		$out.html( html );
	}

	function statCard( label, value ) {
		return '<div style="flex:1;min-width:120px;background:#f8f9fa;border:1px solid #eaecf0;' +
			'border-radius:6px;padding:10px 12px">' +
			'<div style="font-size:11px;color:#72777d;text-transform:uppercase;letter-spacing:0.5px">' +
			esc( label ) + '</div>' +
			'<div style="font-size:18px;font-weight:bold;margin-top:2px">' + esc( value ) + '</div></div>';
	}

	mw.loader.using( [ 'mediawiki.util' ], function () {
		var $link = mw.util.addPortletLink(
			'p-tb', '#', 'User stats', 't-userstats',
			'Look up statistics for any user'
		);
		if ( $link ) {
			$( $link ).on( 'click', function ( e ) {
				e.preventDefault();
				openDialog();
			} );
		}
	} );
}() );