Skill: percent-applications
Percent Applications Practice
An endless supply of never-repeating real-world percent problems with instant feedback. Discounts, sales tax, tips, percent change, successive percents, tax on sale, original prices, and simple interest.
1 Practice
Type your answer and press Check answer (or Enter). A wrong answer earns a hint first;
a second miss walks you through the full solution.
How to enter answers
- Money: type a dollar amount like
64.80or60— the$is optional. - Percents: type the plain number, like
30for 30%.
2 What you’ll practice
Every problem is generated fresh from 12 templates — a problem never repeats until its whole pool is used up.
Discounts & tax
Sale prices, discount amounts, sales-tax amounts and totals — including tax on the sale price.
Tips & change
Tip amounts and bill totals, percent increase and decrease (always change ÷ original).
Tricky combos
Successive percents, finding the original price, and simple interest.
3 Watch out for these
The three traps from the lesson, in one place.
Wrong: $50 → $65: 15/65 ≈ 23%
Right: 15/50 = 30%. The original is the denominator.
Wrong: 8% tax on the $80 tag = $6.40
Right: 8% on the $60 sale price = $4.80.
Wrong: $100 → $70 → $100
Right: $100 → $70 → $91. Percents don’t undo.
/* Fibo percent-applications practice core (pure logic, no DOM). * UMD: node (require) + browser (window.PercentApplicationsPractice). * Money is ALWAYS integer cents. Percent-change answers are exact integers. * API matches the shared page_practice.js glue: * new PracticeSession(seed) -> .next() / .submit(raw) / .reveal() * problem: { promptHTML, tag, answer:{kind,v|cents}, hintHTML, whyHTML, * answerDisplayHTML, accept[], templateId, key, inputs } * Answer kinds: 'money' (cents) and 'num' (plain number; percent answers * display with a % sign via the suffix). */ (function (root, factory) { var api = factory(); if (typeof module === 'undefined') { root.PercentApplicationsPractice = api; } else { module.exports = api; } })(typeof self !== 'undefined' ? self : this, function () { 'use strict';
var PCT = '%';
function mulberry32(seed) { var a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; var t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function ri(rand, lo, hi) { return lo + Math.floor(rand() * (hi - lo + 1)); } function pick(rand, arr) { return arr[Math.floor(rand() * arr.length)]; } function shuffle(arr, rand) { for (var i = arr.length - 1; i > 0; i--) { var j = Math.floor(rand() * (i + 1)); var t = arr[i]; arr[i] = arr[j]; arr[j] = t; } return arr; }
function moneyStr(cents) { return '$' + (cents / 100).toFixed(2); } function priceStr(cents) { return cents % 100 === 0 ? '$' + (cents / 100) : moneyStr(cents); } function numSpan(v) { return '' + v + ''; } function moneySpan(cents) { return '' + priceStr(cents) + ''; } function pctOfCents(p, cents) { return Math.round(p * cents / 100); }
function parseAnswer(raw, kind) { var s = String(raw).trim().replace(/[$,\s]+/g, ''); if (kind === 'money') { if (!/^[+-]?\d+(\.\d{1,2})?$/.test(s)) return null; var c = Math.round(parseFloat(s) * 100); if (!isFinite(c)) return null; return c; } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(s)) return null; var v = parseFloat(s); if (!isFinite(v)) return null; return v; }
function finishProblem(prompt, ans, hint, steps, tag, inputs, suffix) { var disp, plain, v; if (ans.kind === 'money') { v = ans.cents; disp = '' + moneyStr(v) + ''; plain = (v / 100).toFixed(2); } else { v = ans.v; disp = '' + v + (suffix || '') + ''; plain = String(v); } var why = '
- ' +
steps.map(function (s) { return '
- ' + s + '
'; }).join('') + '
' + '
' + '
'; return { promptHTML: prompt, tag: tag, answer: ans, hintHTML: hint, whyHTML: why, answerDisplayHTML: disp, accept: [plain], inputs: inputs || {} }; }
var HINT_DISC = 'Hint: find the discount first (' + PCT + ' of the price), then subtract it from the price.'; var HINT_ADD = 'Hint: find the percent of the base first, then add it to the base.'; var HINT_CHG = 'Hint: percent change = change ÷ original — always divide by where you started.';
/* ---------------- the 12 templates ---------------- */ var TEMPLATES = [ { id: 'DISC_PRICE', label: 'Discounts', enumerate: function (rand) { var pairs = [[80, 25], [200, 15], [120, 30], [90, 20], [75, 40], [50, 10], [60, 50], [150, 20], [96, 25], [140, 30]]; return shuffle(pairs.map(function (pr) { return { cents: pr[0] * 100, p: pr[1] }; }), rand); }, make: function (p) { var save = pctOfCents(p.p, p.cents), price = p.cents - save; return finishProblem( 'A ' + moneySpan(p.cents) + ' jacket is ' + p.p + PCT + ' off. What is the sale price?', { kind: 'money', cents: price }, HINT_DISC, ['Discount = ' + p.p + PCT + ' of ' + moneyStr(p.cents) + ' = ' + moneyStr(save) + '.', 'Sale price = ' + moneyStr(p.cents) + ' − ' + moneyStr(save) + ' = ' + moneyStr(price) + '.', 'Check: you pay ' + (100 - p.p) + PCT + ' → ' + ((100 - p.p) / 100) + ' × ' + moneyStr(p.cents) + ' = ' + moneyStr(price) + '.'], 'Discounts', { cents: p.cents, p: p.p }); } }, { id: 'DISC_AMT', label: 'Discounts', enumerate: function (rand) { var pairs = [[80, 25], [200, 15], [120, 30], [90, 20], [75, 40], [60, 50]]; return shuffle(pairs.map(function (pr) { return { cents: pr[0] * 100, p: pr[1] }; }), rand); }, make: function (p) { var save = pctOfCents(p.p, p.cents); return finishProblem( 'A ' + moneySpan(p.cents) + ' jacket is ' + p.p + PCT + ' off. How much do you save?', { kind: 'money', cents: save }, HINT_DISC, ['"Save" means the discount: ' + p.p + PCT + ' of ' + moneyStr(p.cents) + '.', p.p + PCT + ' → ' + (p.p / 100) + '; ' + (p.p / 100) + ' × ' + moneyStr(p.cents) + ' = ' + moneyStr(save) + '.'], 'Discounts', { cents: p.cents, p: p.p }); } }, { id: 'TAX_TOTAL', label: 'Sales tax', enumerate: function (rand) { var pairs = [[45, 8], [90, 6], [120, 8], [75, 10], [200, 5], [60, 8], [150, 6]]; return shuffle(pairs.map(function (pr) { return { cents: pr[0] * 100, r: pr[1] }; }), rand); }, make: function (p) { var tax = pctOfCents(p.r, p.cents), total = p.cents + tax; return finishProblem( 'A ' + moneySpan(p.cents) + ' meal has ' + p.r + PCT + ' sales tax. What is the total?', { kind: 'money', cents: total }, HINT_ADD, ['Tax = ' + p.r + PCT + ' of ' + moneyStr(p.cents) + ' = ' + moneyStr(tax) + '.', 'Total = ' + moneyStr(p.cents) + ' + ' + moneyStr(tax) + ' = ' + moneyStr(total) + '.'], 'Sales tax', { cents: p.cents, r: p.r }); } }, { id: 'TAX_AMT', label: 'Sales tax', enumerate: function (rand) { var pairs = [[45, 8], [90, 6], [120, 8], [75, 10], [200, 5]]; return shuffle(pairs.map(function (pr) { return { cents: pr[0] * 100, r: pr[1] }; }), rand); }, make: function (p) { var tax = pctOfCents(p.r, p.cents); return finishProblem( 'A ' + moneySpan(p.cents) + ' meal has ' + p.r + PCT + ' sales tax. How much is the tax?', { kind: 'money', cents: tax }, HINT_ADD, ['Tax = ' + p.r + PCT + ' of ' + moneyStr(p.cents) + '.', p.r + PCT + ' → ' + (p.r / 100) + '; ' + (p.r / 100) + ' × ' + moneyStr(p.cents) + ' = ' + moneyStr(tax) + '.'], 'Sales tax', { cents: p.cents, r: p.r }); } }, { id: 'TIP_AMT', label: 'Tips', enumerate: function (rand) { var pairs = [[60, 15], [75, 20], [40, 18], [50, 20], [88, 15], [120, 18]]; return shuffle(pairs.map(function (pr) { return { cents: pr[0] * 100, p: pr[1] }; }), rand); }, make: function (p) { var tip = pctOfCents(p.p, p.cents); return finishProblem( 'The restaurant bill is ' + moneySpan(p.cents) + ' and you leave a ' + p.p + PCT + ' tip. How much is the tip?', { kind: 'money', cents: tip }, HINT_ADD, ['Tip = ' + p.p + PCT + ' of ' + moneyStr(p.cents) + '.', p.p + PCT + ' → ' + (p.p / 100) + '; ' + (p.p / 100) + ' × ' + moneyStr(p.cents) + ' = ' + moneyStr(tip) + '.'], 'Tips', { cents: p.cents, p: p.p }); } }, { id: 'TIP_TOTAL', label: 'Tips', enumerate: function (rand) { var pairs = [[60, 15], [75, 20], [40, 18], [50, 20], [88, 15]]; return shuffle(pairs.map(function (pr) { return { cents: pr[0] * 100, p: pr[1] }; }), rand); }, make: function (p) { var tip = pctOfCents(p.p, p.cents), total = p.cents + tip; return finishProblem( 'The restaurant bill is ' + moneySpan(p.cents) + ' with a ' + p.p + PCT + ' tip. What is the total?', { kind: 'money', cents: total }, HINT_ADD, ['Tip = ' + p.p + PCT + ' of ' + moneyStr(p.cents) + ' = ' + moneyStr(tip) + '.', 'Total = ' + moneyStr(p.cents) + ' + ' + moneyStr(tip) + ' = ' + moneyStr(total) + '.'], 'Tips', { cents: p.cents, p: p.p }); } }, { id: 'PCT_INC', label: 'Percent change', enumerate: function (rand) { // [old, new] pairs with exact integer percent increases var pairs = [[50, 65], [40, 52], [20, 25], [80, 100], [60, 75], [200, 250], [120, 150]]; return shuffle(pairs.map(function (pr) { return { a: pr[0] * 100, b: pr[1] * 100 }; }), rand); }, make: function (p) { var chg = p.b - p.a, pct = Math.round(chg / p.a * 100); return finishProblem( 'A price rises from ' + moneySpan(p.a) + ' to ' + moneySpan(p.b) + '. What is the percent increase?', { kind: 'num', v: pct }, HINT_CHG, ['Change = ' + moneyStr(p.b) + ' − ' + moneyStr(p.a) + ' = ' + moneyStr(chg) + '.', 'Percent change = change ÷ original = ' + moneyStr(chg) + ' ÷ ' + moneyStr(p.a) + ' = ' + (chg / p.a) + ' = ' + pct + PCT + '.', 'Check: ' + pct + PCT + ' of ' + moneyStr(p.a) + ' = ' + moneyStr(pctOfCents(pct, p.a)) + ' — that is the change.'], 'Percent change', { a: p.a, b: p.b }, PCT); } }, { id: 'PCT_DEC', label: 'Percent change', enumerate: function (rand) { var pairs = [[80000, 60000], [50, 40], [200, 150], [80, 60], [120, 90], [60, 45]]; return shuffle(pairs.map(function (pr) { return { a: pr[0] * 100, b: pr[1] * 100 }; }), rand); }, make: function (p) { var chg = p.a - p.b, pct = Math.round(chg / p.a * 100); return finishProblem( 'A value drops from ' + moneySpan(p.a) + ' to ' + moneySpan(p.b) + '. What is the percent decrease?', { kind: 'num', v: pct }, HINT_CHG, ['Change = ' + moneyStr(p.a) + ' − ' + moneyStr(p.b) + ' = ' + moneyStr(chg) + '.', 'Percent change = change ÷ original = ' + moneyStr(chg) + ' ÷ ' + moneyStr(p.a) + ' = ' + (chg / p.a) + ' = ' + pct + PCT + '.', 'Trap check: dividing by the NEW value (' + moneyStr(p.b) + ') would give the wrong answer.'], 'Percent change', { a: p.a, b: p.b }, PCT); } }, { id: 'SUCC', label: 'Successive percents', enumerate: function (rand) { // [start, up%, down%] with exact-cent results var triples = [[60, 25, 20], [80, 25, 20], [100, 20, 10], [200, 50, 20], [120, 25, 40], [50, 20, 10]]; return shuffle(triples.map(function (t) { return { cents: t[0] * 100, up: t[1], down: t[2] }; }), rand); }, make: function (p) { var mid = Math.round(p.cents * (1 + p.up / 100)); var fin = Math.round(mid * (1 - p.down / 100)); return finishProblem( 'A store raises a ' + moneySpan(p.cents) + ' item by ' + p.up + PCT + ', then discounts the new price by ' + p.down + PCT + '. What is the final price?', { kind: 'money', cents: fin }, 'Hint: do it in order — the second percent acts on the new price, not the original.', ['After the raise: ' + moneyStr(p.cents) + ' × ' + (1 + p.up / 100) + ' = ' + moneyStr(mid) + '.', 'After the discount: ' + moneyStr(mid) + ' × ' + (1 - p.down / 100) + ' = ' + moneyStr(fin) + '.', 'Notice: +' + p.up + PCT + ' then −' + p.down + PCT + ' does NOT return to ' + moneyStr(p.cents) + ' — percents do not undo each other.'], 'Successive percents', { cents: p.cents, up: p.up, down: p.down }); } }, { id: 'TAX_ON_SALE', label: 'Tax on sale', enumerate: function (rand) { // [price, discount%, tax%] with exact-cent results var triples = [[80, 25, 8], [200, 15, 6], [120, 30, 8], [60, 50, 10], [150, 20, 8]]; return shuffle(triples.map(function (t) { return { cents: t[0] * 100, d: t[1], r: t[2] }; }), rand); }, make: function (p) { var sale = p.cents - pctOfCents(p.d, p.cents); var tax = pctOfCents(p.r, sale), total = sale + tax; return finishProblem( 'A ' + moneySpan(p.cents) + ' jacket is ' + p.d + PCT + ' off, and there is ' + p.r + PCT + ' sales tax. What is the total you pay?', { kind: 'money', cents: total }, 'Hint: discount first, then tax — tax applies to the sale price.', ['Sale price = ' + moneyStr(p.cents) + ' − ' + moneyStr(pctOfCents(p.d, p.cents)) + ' = ' + moneyStr(sale) + '.', 'Tax = ' + p.r + PCT + ' of ' + moneyStr(sale) + ' = ' + moneyStr(tax) + '.', 'Total = ' + moneyStr(sale) + ' + ' + moneyStr(tax) + ' = ' + moneyStr(total) + '.', 'Trap: tax on the original ' + moneyStr(p.cents) + ' would be ' + moneyStr(pctOfCents(p.r, p.cents)) + ' — wrong, because you do not pay tax on money you never spend.'], 'Tax on sale', { cents: p.cents, d: p.d, r: p.r }); } }, { id: 'ORIG_PRICE', label: 'Find the original', enumerate: function (rand) { // [saleCents, discount%] with exact originals var pairs = [[6000, 25], [8400, 30], [4250, 15], [7200, 20], [9000, 10]]; return shuffle(pairs.map(function (pr) { return { sale: pr[0], p: pr[1] }; }), rand); }, make: function (p) { var orig = Math.round(p.sale / (1 - p.p / 100)); return finishProblem( 'A jacket is on sale for ' + moneySpan(p.sale) + ' after a ' + p.p + PCT + ' discount. What was the original price?', { kind: 'money', cents: orig }, 'Hint: the sale price is ' + (100 - p.p) + PCT + ' of the original — work backwards by dividing.', ['Sale price = ' + (100 - p.p) + PCT + ' of original.', 'Original = ' + moneyStr(p.sale) + ' ÷ ' + ((100 - p.p) / 100) + ' = ' + moneyStr(orig) + '.', 'Check: ' + p.p + PCT + ' off ' + moneyStr(orig) + ' = ' + moneyStr(pctOfCents(p.p, orig)) + ' off → ' + moneyStr(orig - pctOfCents(p.p, orig)) + '.'], 'Find the original', { sale: p.sale, p: p.p }); } }, { id: 'SIMPLE_INT', label: 'Simple interest', enumerate: function (rand) { // [principalCents, rate%, years] with exact-cent interest var triples = [[100000, 5, 2], [50000, 4, 3], [200000, 3, 2], [80000, 5, 1], [150000, 6, 2]]; return shuffle(triples.map(function (t) { return { princ: t[0], r: t[1], y: t[2] }; }), rand); }, make: function (p) { var interest = Math.round(p.princ * p.r / 100 * p.y); return finishProblem( moneySpan(p.princ) + ' is invested at ' + p.r + PCT + ' simple interest per year for ' + p.y + ' year' + (p.y === 1 ? '' : 's') + '. How much interest is earned?', { kind: 'money', cents: interest }, 'Hint: simple interest = principal × rate × time.', ['Interest = ' + moneyStr(p.princ) + ' × ' + (p.r / 100) + ' × ' + p.y + '.', 'One year: ' + moneyStr(pctOfCents(p.r, p.princ)) + '; ' + p.y + ' years: ' + moneyStr(interest) + '.'], 'Simple interest', { princ: p.princ, r: p.r, y: p.y }); } } ];
/* ---------------- session: scoring, streaks, no-repeat ---------------- */
function PracticeSession(seed) {
this._rand = mulberry32(seed >>> 0);
this.pools = {};
this.seen = {};
this.score = 0;
this.streak = 0;
this.problemNum = 0;
this.current = null;
this.wrongCount = 0;
this.revealed = false;
this._done = false;
}
PracticeSession.prototype._draw = function () {
for (var guard = 0; guard < 200; guard++) {
var t = TEMPLATES[Math.floor(this._rand() * TEMPLATES.length)];
var pool = this.pools[t.id];
if (!pool || pool.length === 0) {
pool = t.enumerate(this._rand).slice();
this.pools[t.id] = pool;
}
var p = pool.pop();
var key = t.id + '|' + JSON.stringify(p);
if (this.seen[key]) continue;
this.seen[key] = 1;
var prob = t.make(p);
prob.templateId = t.id;
prob.key = key;
return prob;
}
this.pools = {}; this.seen = {};
var t2 = TEMPLATES[0], p2 = t2.enumerate(this._rand)[0];
var pr = t2.make(p2);
pr.templateId = t2.id; pr.key = t2.id + '|' + JSON.stringify(p2);
return pr;
};
PracticeSession.prototype.next = function () {
this.current = this._draw();
this.wrongCount = 0;
this.revealed = false;
this._done = false;
this.problemNum++;
return this.current;
};
function answersEqual(pr, parsed) {
if (parsed === null) return false;
if (pr.answer.kind === 'money') return parsed === pr.answer.cents;
return parsed === pr.answer.v;
}
PracticeSession.prototype.submit = function (raw) {
var pr = this.current;
if (!pr) return { status: 'none' };
if (this._done) return { status: 'already' };
var parsed = parseAnswer(raw, pr.answer.kind);
if (parsed === null) return { status: 'invalid' };
var ok = answersEqual(pr, parsed);
if (ok) {
this._done = true;
var firstTry = !(this.wrongCount !== 0 || this.revealed);
if (!this.revealed) {
this.streak++;
if (firstTry) this.score++;
}
return { status: 'correct', firstTry: firstTry, score: this.score, streak: this.streak, answerHTML: pr.answerDisplayHTML };
}
this.wrongCount++;
this.streak = 0;
if (this.wrongCount === 1) return { status: 'hint', hintHTML: pr.hintHTML, streak: 0 };
this.revealed = true;
return { status: 'why', whyHTML: pr.whyHTML, streak: 0 };
};
PracticeSession.prototype.reveal = function () {
if (!this.current) return null;
this.revealed = true;
return this.current.whyHTML;
};
return {
PracticeSession: PracticeSession,
TEMPLATES: TEMPLATES,
parseAnswer: parseAnswer,
moneyStr: moneyStr,
priceStr: priceStr,
pctOfCents: pctOfCents
};
});
/* Shared Fibo practice page glue. Config (set by an inline script before this file): window.FIBO_PRACTICE = { core: 'IntegerPractice', placeholder: '...', entryHelpHTML: '...' } The core must expose: PracticeSession, with .next()/.submit()/.reveal(). Problem objects must provide: tag, promptHTML, answer.kind, hintHTML, whyHTML, answerDisplayHTML. Answers are checked inside the core. */ (function () { 'use strict'; var CFG = window.FIBO_PRACTICE || {}; var CORE = window[CFG.core || '']; if (!CORE) { return; } var session = new CORE.PracticeSession((Date.now() % 2000000000) + 7); function el(id) { return document.getElementById(id); } var input = el('ansInput'), checkBtn = el('checkBtn'), anotherBtn = el('anotherBtn'), showSolBtn = el('showSolBtn'), promptTag = el('promptTag'), promptEl = el('promptEl'), fb = el('fb'), hintBox = el('hintBox'), whyBox = el('whyBox'), statsLine = el('statsLine');
function updateStats() { statsLine.innerHTML = 'Score ' + session.score + ' · Streak ' + session.streak + ' · Problem ' + session.problemNum + ''; } function clearPanels() { fb.className = 'fb'; fb.innerHTML = ''; hintBox.className = 'hintbox'; hintBox.innerHTML = ''; whyBox.className = 'whybox'; whyBox.innerHTML = ''; } function showFb(kind, html) { fb.className = 'fb show ' + kind; fb.innerHTML = html; } function entryHint() { return CFG.entryHelpHTML || 'Type your answer and press Check answer.'; } function newProblem() { var pr = session.next(); promptTag.textContent = pr.tag; promptEl.innerHTML = pr.promptHTML; input.value = ''; if (CFG.placeholder) { input.placeholder = CFG.placeholder; } clearPanels(); showSolBtn.style.display = ''; updateStats(); input.focus(); } function showWhy() { whyBox.className = 'whybox show'; whyBox.innerHTML = '
' + session.current.whyHTML; showSolBtn.style.display = 'none'; } checkBtn.addEventListener('click', function () { var r = session.submit(input.value); updateStats(); if (r.status === 'invalid') { showFb('warn', 'Could not read that. ' + entryHint()); return; } if (r.status === 'already' || r.status === 'none') { return; } if (r.status === 'correct') { if (r.firstTry) { showFb('ok', 'Correct! +1 point. The answer is ' + r.answerHTML + ''); } else if (session.revealed) { showFb('ok', 'That is right. You already saw the solution, so the streak is unchanged. ' + 'Hit Try another for a fresh problem.'); } else { showFb('ok', 'Correct! The answer is ' + r.answerHTML + '. ' + 'Solved after a hint, so no point this time — but the streak grows.'); } showSolBtn.style.display = 'none'; return; } if (r.status === 'hint') { showFb('no', 'Not quite. Try again — here is a hint.'); hintBox.className = 'hintbox show'; hintBox.innerHTML = r.hintHTML; input.focus(); input.select(); return; } if (r.status === 'why') { showFb('no', 'Not quite. Let us walk through it step by step.'); showWhy(); return; } }); anotherBtn.addEventListener('click', newProblem); showSolBtn.addEventListener('click', function () { session.reveal(); showWhy(); updateStats(); }); input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { checkBtn.click(); } }); newProblem(); })();