Before this lesson

Understand numeric types, input validation, array totals and local storage. The exercise uses one fixed currency.

What you will be able to do

Store integer minor units and implement category filtering, totals and local recovery.

Understand the mechanism

This is a fixed-currency expense-recording exercise, not banking integration. Each record has an ID, description, category and integer minor units. Changing currency requires checking its unit rules, not merely replacing a symbol.

Validate decimal text and at most two fractional digits before converting components to integer cents. Reject zero, negatives, scientific notation, excessive precision and oversized amounts rather than silently rounding.

Filter then sum integer cents and format only for display. Label the result as the visible total, which intentionally differs from the board’s whole-dataset metric. Delete by identity so identical descriptions remain separate records.

Validate restored shape, categories and amount bounds so malformed data cannot corrupt totals. Preserve in-memory operation on save failure with clear feedback. Local-only storage provides neither cross-device sync nor audit history; use fictional practice records.

Runnable example

Run HTML examples as separate .html files in a modern browser. Prepare resource files and multi-file modules as described in the lesson.

<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Expense notebook</title><style>
*{box-sizing:border-box}body{max-width:850px;margin:auto;padding:24px;font:17px/1.6 system-ui;color:#20362c;background:#f7faf4}button,input,select{font:inherit;padding:8px;max-width:100%}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #176343;outline-offset:3px}form{display:flex;flex-wrap:wrap;gap:12px;align-items:end}label{display:block}input,select{display:block}li{margin:12px 0;padding:12px;background:white;border:1px solid #d4dfd2;border-radius:8px;overflow-wrap:anywhere}li button{margin:4px}progress{width:100%}.row{display:flex;gap:12px;flex-wrap:wrap}output{font-size:2rem;font-variant-numeric:tabular-nums}small{display:block}table{border-collapse:collapse;width:100%}td,th{padding:10px;text-align:left;border-bottom:1px solid #ccc}.table-wrap{overflow:auto}h1{font-size:clamp(26px,5vw,38px)}
</style></head><body><main><h1>Expense notebook</h1><p>Practice data only. This is a local record-keeping demo.</p>
<form id="entry"><label>Description<input id="description" required maxlength="60"></label><label>Amount<input id="amount" inputmode="decimal" placeholder="12.50" required></label><label>Category<select id="category"><option value="learning">Learning</option><option value="daily">Daily</option><option value="other">Other</option></select></label><button>Add expense</button></form>
<p id="message" role="status"></p><label for="filter">Filter category</label><select id="filter"><option value="all">All</option><option value="learning">Learning</option><option value="daily">Daily</option><option value="other">Other</option></select><p id="total"></p><ul id="entries"></ul></main><script>
const KEY='honeysuck-project:expenses:v1',categories=['learning','daily','other'];let entries=[];
const message=document.querySelector('#message'),list=document.querySelector('#entries'),filter=document.querySelector('#filter');
function cents(raw){const text=raw.trim();if(!/^(0|[1-9][0-9]{0,5})(\.[0-9]{1,2})?$/.test(text))return null;const [whole,fraction='']=text.split('.');const value=Number(whole)*100+Number(fraction.padEnd(2,'0'));return value>0&&Number.isSafeInteger(value)?value:null;}
const money=new Intl.NumberFormat('en-US',{style:'currency',currency:'USD'});
try{const value=JSON.parse(localStorage.getItem(KEY)||'[]');const seen=new Set();if(Array.isArray(value))entries=value.filter(x=>{if(!x||typeof x.id!=='string'||seen.has(x.id)||typeof x.description!=='string'||!x.description.trim()||x.description.length>60||!Number.isSafeInteger(x.cents)||x.cents<=0||x.cents>99999999||!categories.includes(x.category))return false;seen.add(x.id);return true;}).slice(0,500);}catch{message.textContent='Saved entries could not be read. Starting empty.';}
function save(){try{localStorage.setItem(KEY,JSON.stringify(entries));}catch{message.textContent='Could not save. Keep a separate copy before leaving.';}}
function render(){const visible=entries.filter(x=>filter.value==='all'||x.category===filter.value);const sum=visible.reduce((n,x)=>n+x.cents,0);document.querySelector('#total').textContent=`${visible.length} entries · Visible total: ${money.format(sum/100)}`;list.replaceChildren();for(const entry of visible){const li=document.createElement('li'),text=document.createElement('span'),button=document.createElement('button');text.textContent=`${entry.description} · ${entry.category} · ${money.format(entry.cents/100)}`;button.type='button';button.textContent='Delete';button.setAttribute('aria-label','Delete '+entry.description);button.addEventListener('click',()=>{entries=entries.filter(x=>x.id!==entry.id);message.textContent='Entry removed.';save();render();filter.focus();});li.append(text,button);list.append(li);}if(!visible.length){const li=document.createElement('li');li.dataset.empty='true';li.textContent='No entries in this view.';list.append(li);}}
document.querySelector('#entry').addEventListener('submit',event=>{event.preventDefault();const description=document.querySelector('#description').value.trim(),amount=cents(document.querySelector('#amount').value),category=document.querySelector('#category').value;if(!description||description.length>60||amount===null||!categories.includes(category)||entries.length>=500){message.textContent='Use a description and a positive amount up to 999999.99 with at most two decimals; maximum 500 entries.';return;}entries.push({id:crypto.randomUUID(),description,cents:amount,category});document.querySelector('#description').value='';document.querySelector('#amount').value='';message.textContent='Expense added.';save();render();document.querySelector('#description').focus();});filter.addEventListener('change',render);render();
</script></body></html>

Trace and verify

Add 0.10 and 0.20 for an exact 30-cent total. Filter categories and verify visible-only sums. Reject excess precision, negative, exponential, empty and zero input; 12.5 becomes 1250 cents. Test duplicate descriptions, deletion, reload and invalid stored amounts.

Common mistakes and boundaries

Formatting still uses numbers, but accumulation uses integer cents. The record and amount caps keep totals within safe integer bounds. This simplified model does not provide production accounting audit or consistency guarantees.

Practice challenge

Add category summaries and a filter-reset button that preserves records. Write normal and boundary tests for amount parsing.

Reference solution and reasoning

Aggregate each allowed category in integer units and format consistently. Reset only changes the filter. Test the minimum, maximum, over-limit and leading-zero cases to keep behavior aligned with the documented input policy.

Project lab: parsing decimal money into integer cents

Floating-point rounding is only one risk. parseFloat accepts a numeric prefix such as 12 in 12abc, while exponents and extra decimal places may violate the intended format. Define a strict two-decimal currency grammar, bound the integer part to nine digits and convert directly to integer cents.

Run this separate experiment in a modern browser Console; it is not a replacement for the main example above.

function parseCents(text) {
  if (typeof text !== 'string' || text.length > 12) throw new Error('Invalid amount');
  const match = /^(0|[1-9][0-9]{0,8})(?:\.([0-9]{1,2}))?(?![\s\S])/.exec(text);
  if (!match) throw new Error('Invalid amount');
  return Number(match[1]) * 100 + Number((match[2] || '').padEnd(2, '0'));
}
console.log(parseCents('0.10') + parseCents('0.20'));
console.log(parseCents('12.3'));
for (const input of ['12abc', '1e3', '1.234', '-1', '01', '1\n']) {
  try { parseCents(input); console.log('accepted'); }
  catch { console.log('rejected'); }
}

Expected observations

Output is 30, 1230 and six rejected lines. The value 30 means thirty cents, not thirty currency units; presentation converts it to a currency display.

Why this happens and where it stops applying

This contract intentionally rejects negatives, spaces, grouping commas and full-width digits; explain accepted input in the UI. Integer cents make these additions exact, but an unbounded total can still exceed the safe integer range. Bound record count and check Number.isSafeInteger on totals. Currency-specific precision, exchange rates, taxation and rounding policies require a separate design beyond this two-decimal exercise.

Extension challenge

If the product permits surrounding whitespace but not internal spaces, where should normalization happen?

Reasoned solution

Trim explicitly at the input boundary before calling the parser. Test that " 12.30 " succeeds and "12 .30" fails. Sprinkling \s* throughout the pattern silently broadens the accepted grammar.

Further reading

Complete source and acceptance

Download the complete project source (.html.txt)

Save the downloaded text as a .html file in a separate practice directory and open it in your browser. It contains the main project above; the later labs are separate exercises. Data stays in that browser origin, not a server account.