Jump to content

MediaWiki:Gadget-fa-bid-calculator.js: Difference between revisions

From 1TC Rules
Bid to beat must clear the standing bid by 0.2 points
The league is NBA RealGM
 
(One intermediate revision by the same user not shown)
Line 1: Line 1:
/**
/**
  * 1TC RealGM — free agency bid calculator.
  * NBA RealGM — free agency bid calculator.
  * Implements the points system on [[NBA:Free Agency]]. If the two ever disagree,
  * Implements the points system on [[NBA:Free Agency]]. If the two ever disagree,
  * the wiki page is the rule and this script is the bug.
  * the wiki page is the rule and this script is the bug.
Line 175: Line 175:
var team = document.createElement( 'input' );
var team = document.createElement( 'input' );
team.type = 'text';
team.type = 'text';
team.placeholder = 'Suns';
team.placeholder = 'Sonics';
team.className = 'tc-calc-wide';
team.className = 'tc-calc-wide';


Line 288: Line 288:


// --- ready-to-paste post ---
// --- ready-to-paste post ---
// "Suns offer Caldwell Jones 2 years @ $500,000 = 3.5 points".
// "Sonics offer Caldwell Jones 2 years @ $500,000 = 3.5 points".
// Whatever of team/player is missing simply drops out.
// Whatever of team/player is missing simply drops out.
var tm = team.value.trim(),
var tm = team.value.trim(),

Latest revision as of 21:42, 8 October 2026

/**
 * NBA RealGM — free agency bid calculator.
 * Implements the points system on [[NBA:Free Agency]]. If the two ever disagree,
 * the wiki page is the rule and this script is the bug.
 */
( function () {
	'use strict';

	var NEW_POST_URL = 'https://2teams1cup.com/forum/index.php?act=post&do=new_post&f=67';

	var MIN_OFFER = 500000,
		MIN_RAISE = 200000,
		MIN_RAISE_POINTS = 0.2,
		MAX_YEARS = 4,
		MAX_YEARS_35 = 2,
		YEAR_POINTS = 1.5;


	function money( n ) {
		return '$' + n.toLocaleString( 'en-US' );
	}

	/** Digits only, so "4,000,000" and "4000000" both read as 4000000. */
	function parseMoney( v ) {
		var digits = String( v ).replace( /[^0-9]/g, '' );
		return digits === '' ? NaN : Number( digits );
	}

	/**
	 * Reformat a text input with thousands separators, keeping the caret where
	 * the typist left it. A plain reformat would send it to the end, which makes
	 * editing the middle of a figure unusable.
	 */
	function formatWithCaret( el ) {
		var before = el.value.slice( 0, el.selectionStart ),
			digitsBefore = before.replace( /[^0-9]/g, '' ).length,
			n = parseMoney( el.value );

		el.value = isNaN( n ) ? '' : n.toLocaleString( 'en-US' );

		// Walk forward past the same number of digits to place the caret.
		var pos = 0, seen = 0;
		while ( pos < el.value.length && seen < digitsBefore ) {
			if ( /[0-9]/.test( el.value[ pos ] ) ) {
				seen++;
			}
			pos++;
		}
		el.setSelectionRange( pos, pos );
	}

	function pointWord( n ) {
		return '= ' + pts( n ) + ( pts( n ) === 1 ? ' point' : ' points' );
	}

	function points( years, salary ) {
		return ( years * YEAR_POINTS ) + ( salary / 1000000 );
	}

	/** Round a points total for display without trailing float noise. */
	function pts( n ) {
		return Math.round( n * 100 ) / 100;
	}


	/** A monospace post block with a copy button. */
	function postBlock( text ) {
		var wrap = document.createElement( 'div' );
		wrap.className = 'tc-calc-postwrap';

		var pre = document.createElement( 'div' );
		pre.className = 'tc-calc-post';
		pre.textContent = text;
		wrap.appendChild( pre );

		var btn = document.createElement( 'button' );
		btn.className = 'tc-calc-copy';
		btn.type = 'button';
		btn.textContent = 'Copy';
		btn.addEventListener( 'click', function () {
			function done( ok ) {
				btn.textContent = ok ? 'Copied' : 'Press \u2318C / Ctrl+C';
				setTimeout( function () { btn.textContent = 'Copy'; }, 2000 );
			}
			if ( navigator.clipboard && navigator.clipboard.writeText ) {
				navigator.clipboard.writeText( text ).then( function () { done( true ); },
					function () { selectFallback( pre ); done( false ); } );
			} else {
				selectFallback( pre );
				done( false );
			}
		} );
		wrap.appendChild( btn );

		var actions = document.createElement( 'div' );
		actions.className = 'tc-calc-actions';
		var link = document.createElement( 'a' );
		link.href = NEW_POST_URL;
		link.target = '_blank';
		link.rel = 'noopener';
		link.textContent = 'Copy and start a new topic in NBA: Free Agency';
		// IPB has no parameter for seeding the post body, so the next best thing
		// is to put it on the clipboard on the way out.
		link.addEventListener( 'click', function () {
			if ( navigator.clipboard && navigator.clipboard.writeText ) {
				navigator.clipboard.writeText( text );
			}
		} );
		actions.appendChild( link );

		var outer = document.createElement( 'div' );
		outer.appendChild( wrap );
		outer.appendChild( actions );
		return outer;
	}

	/** Clipboard API unavailable or refused: select the text so the reader can copy it. */
	function selectFallback( el ) {
		var range = document.createRange();
		range.selectNodeContents( el );
		var sel = window.getSelection();
		sel.removeAllRanges();
		sel.addRange( range );
	}

	function build( container ) {
		var form = document.createElement( 'div' );
		form.className = 'tc-calc';

		/**
		 * One labelled row. Controls live in their own flex wrapper so that on a
		 * narrow screen the label can stack above them while they stay in a row.
		 * Returns the wrapper, so callers can append a readout beside the control.
		 */
		function row( labelText, el ) {
			var r = document.createElement( 'div' );
			r.className = 'tc-calc-row';
			var l = document.createElement( 'label' );
			l.textContent = labelText;
			var c = document.createElement( 'div' );
			c.className = 'tc-calc-controls';
			c.appendChild( el );
			r.appendChild( l );
			r.appendChild( c );
			form.appendChild( r );
			return c;
		}

		var years = document.createElement( 'select' );
		// A number input cannot display separators — browsers reject a comma as
		// invalid number syntax — so this is a text input formatted by hand.
		var salary = document.createElement( 'input' );
		salary.type = 'text';
		salary.inputMode = 'numeric';
		salary.autocomplete = 'off';
		salary.className = 'tc-calc-money';
		salary.value = MIN_OFFER.toLocaleString( 'en-US' );
		salary.addEventListener( 'input', function () {
			formatWithCaret( salary );
		} );

		var age35 = document.createElement( 'input' );
		age35.type = 'checkbox';

		var beat = document.createElement( 'input' );
		beat.type = 'number';
		beat.step = '0.1';
		beat.placeholder = '8.5';

		var name = document.createElement( 'input' );
		name.type = 'text';
		name.placeholder = 'Caldwell Jones';
		name.className = 'tc-calc-wide';

		var team = document.createElement( 'input' );
		team.type = 'text';
		team.placeholder = 'Sonics';
		team.className = 'tc-calc-wide';

		function fillYears() {
			var max = age35.checked ? MAX_YEARS_35 : MAX_YEARS,
				prev = Number( years.value ) || 1;
			years.textContent = '';
			for ( var y = 1; y <= max; y++ ) {
				var o = document.createElement( 'option' );
				o.value = String( y );
				o.textContent = y + ( y === 1 ? ' year' : ' years' );
				years.appendChild( o );
			}
			years.value = String( Math.min( prev, max ) );
		}
		fillYears();

		var yearPts = document.createElement( 'span' );
		yearPts.className = 'tc-calc-inline';
		row( 'Contract length', years ).appendChild( yearPts );

		var salaryPts = document.createElement( 'span' );
		salaryPts.className = 'tc-calc-inline';
		row( 'Salary', salary ).appendChild( salaryPts );
		row( 'Player is 35+', age35 ).parentNode
			.querySelector( 'label' ).style.fontWeight = 'normal';
		row( 'Bid to beat (optional)', beat );
		row( 'Your team (optional)', team );
		row( 'Player (optional)', name );

		var out = document.createElement( 'div' );
		out.className = 'tc-calc-out';
		form.appendChild( out );

		function render() {
			var y = Number( years.value ),
				s = parseMoney( salary.value ),
				max = age35.checked ? MAX_YEARS_35 : MAX_YEARS;

			out.textContent = '';

			if ( !s || isNaN( s ) ) {
				yearPts.textContent = '';
				salaryPts.textContent = '';
				var empty = document.createElement( 'p' );
				empty.className = 'tc-calc-note';
				empty.textContent = 'Enter a salary.';
				out.appendChild( empty );
				return;
			}

			var total = points( y, s );
			yearPts.textContent = pointWord( y * YEAR_POINTS );
			salaryPts.textContent = pointWord( s / 1000000 );

			var big = document.createElement( 'div' );
			big.className = 'tc-calc-big';
			big.textContent = pts( total ) + ' points';
			out.appendChild( big );


			// --- validity ---
			var problems = [];
			if ( s < MIN_OFFER ) {
				problems.push( 'Below the ' + money( MIN_OFFER ) + ' minimum offer.' );
			}
			if ( y > max ) {
				problems.push( 'Maximum length is ' + max + ' years' +
					( age35.checked ? ' for a player 35+' : '' ) + '.' );
			}
			if ( problems.length ) {
				var warn = document.createElement( 'div' );
				warn.className = 'tc-calc-warn';
				warn.textContent = problems.join( ' ' );
				out.appendChild( warn );
			}

			// --- what it takes to beat a standing bid ---
			var target = Number( beat.value );
			if ( target && !isNaN( target ) ) {
				var box = document.createElement( 'div' );
				box.className = 'tc-calc-note';

				// A bid must clear the standing one by at least MIN_RAISE_POINTS, so
				// the bar is target + 0.2, not merely more than target. Everything
				// here is counted in tenths of a point: 0.1 points is exactly
				// $100,000 and a year is exactly 15 tenths, so integer arithmetic
				// gets the answer that 0.2 + 2.0 in binary floating point does not.
				var requiredTenths = Math.round( target * 10 ) + ( MIN_RAISE_POINTS * 10 );

				if ( Math.round( total * 10 ) >= requiredTenths ) {
					box.textContent = 'This already beats ' + pts( target ) + ' points.';
				} else {
					var salaryTenths = requiredTenths - y * ( YEAR_POINTS * 10 ),
						newSalary = Math.max( salaryTenths * 100000, MIN_OFFER );
					box.textContent = 'To beat ' + pts( target ) + ' points at ' + y +
							( y === 1 ? ' year' : ' years' ) + ': ' + money( newSalary ) +
							' (' + pts( points( y, newSalary ) ) + ' points). ';

					var use = document.createElement( 'button' );
					use.type = 'button';
					use.className = 'tc-calc-use';
					use.textContent = 'Use ' + money( newSalary );
					use.addEventListener( 'click', function () {
						salary.value = newSalary.toLocaleString( 'en-US' );
						render();
					} );
					box.appendChild( use );
				}
				out.appendChild( box );
			}

			// --- ready-to-paste post ---
			// "Sonics offer Caldwell Jones 2 years @ $500,000 = 3.5 points".
			// Whatever of team/player is missing simply drops out.
			var tm = team.value.trim(),
				pl = name.value.trim(),
				prefix = tm && pl ? tm + ' offer ' + pl : ( pl || ( tm ? tm + ' offer' : '' ) ),
				offer = y + ( y === 1 ? ' year' : ' years' ) + ' @ ' + money( s ) +
					' = ' + pts( total ) + ' points';
			out.appendChild( postBlock( prefix ? prefix + ' ' + offer : offer ) );

		}

		age35.addEventListener( 'change', function () { fillYears(); render(); } );
		[ years, salary, beat, name, team ].forEach( function ( el ) {
			el.addEventListener( 'input', render );
			el.addEventListener( 'change', render );
		} );

		container.appendChild( form );
		render();
	}

	mw.hook( 'wikipage.content' ).add( function () {
		var el = document.getElementById( 'tc-fa-bid-calculator' );
		if ( el && !el.dataset.tcBuilt ) {
			el.dataset.tcBuilt = '1';
			build( el );
		}
	} );
}() );