Before this lesson

Understand stable IDs, array filtering, select events and JSON persistence.

What you will be able to do

Build a persistent goal board with stage changes and filtering, deriving progress from the full dataset.

Understand the mechanism

Start with a stable ID, title and one of three mutually exclusive stages rather than separate booleans that can contradict each other. This project explicitly caps 100 goals and 80-character titles. Filtering is separate UI state and never mutates records.

Derive completion from all goals, not visible results; a Done filter must not misleadingly force 100 percent. Define the empty board as zero percent and preserve all records across filtering.

Native selects support keyboard and touch without drag-only interaction. When a stage change removes a record from the active filter, focus returns to the filter; otherwise it returns to that record’s select. Deletion returns focus to the input.

Validate arrays, unique IDs, titles and allowed stages on loading. Save failures do not prevent in-memory operation. A namespaced key isolates this exercise; cloud sync, accounts and collaboration are outside its scope.

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>Learning progress board</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>Learning progress board</h1><p>Add a goal and move it through three stages. Data stays in this browser.</p>
<form id="add"><label>Goal<input id="title" required maxlength="80"></label><button>Add goal</button></form>
<p id="message" role="status"></p><label for="filter">Show stage</label><select id="filter"><option value="all">All</option><option value="planned">Planned</option><option value="learning">Learning</option><option value="done">Done</option></select>
<p id="summary"></p><progress id="progress" max="100" value="0" aria-label="Completed goals"></progress><ul id="goals"></ul></main><script>
const KEY='honeysuck-project:board:v1',stages=['planned','learning','done'];
let goals=[];
const message=document.querySelector('#message'),list=document.querySelector('#goals'),filter=document.querySelector('#filter');
try{const raw=JSON.parse(localStorage.getItem(KEY)||'[]');const seen=new Set();if(Array.isArray(raw))goals=raw.filter(x=>{if(!x||typeof x.id!=='string'||seen.has(x.id)||typeof x.title!=='string'||!x.title.trim()||x.title.length>80||!stages.includes(x.stage))return false;seen.add(x.id);return true;}).slice(0,100);}catch{message.textContent='Saved data could not be loaded. Starting with an empty board.';}
function save(){try{localStorage.setItem(KEY,JSON.stringify(goals));}catch{message.textContent='Could not save. Current changes may be lost after reload.';}}
function render(focusId){
 const done=goals.filter(x=>x.stage==='done').length,percent=goals.length?Math.round(done/goals.length*100):0;
 document.querySelector('#summary').textContent=`${done} of ${goals.length} complete (${percent}%)`;document.querySelector('#progress').value=percent;list.replaceChildren();
 for(const goal of goals.filter(x=>filter.value==='all'||x.stage===filter.value)){
  const li=document.createElement('li');li.dataset.id=goal.id;const title=document.createElement('strong');title.textContent=goal.title;
  const label=document.createElement('label');label.textContent='Stage for '+goal.title;const select=document.createElement('select');
  for(const stage of stages){const option=document.createElement('option');option.value=stage;option.textContent=stage;option.selected=stage===goal.stage;select.append(option);}label.append(select);
  select.addEventListener('change',()=>{goal.stage=select.value;message.textContent='Stage updated.';save();render(goal.id);});
  const remove=document.createElement('button');remove.type='button';remove.textContent='Delete';remove.setAttribute('aria-label','Delete '+goal.title);remove.addEventListener('click',()=>{goals=goals.filter(x=>x.id!==goal.id);message.textContent='Goal removed.';save();render();document.querySelector('#title').focus();});li.append(title,label,remove);list.append(li);
 }
 if(!list.children.length){const li=document.createElement('li');li.textContent=goals.length?'No goals in this stage.':'No goals yet. Add your first goal.';li.dataset.empty='true';list.append(li);}
 if(focusId){const row=[...list.children].find(x=>x.dataset.id===focusId);(row?.querySelector('select')||filter).focus();}
}
document.querySelector('#add').addEventListener('submit',event=>{event.preventDefault();const input=document.querySelector('#title'),title=input.value.trim();if(!title||title.length>80||goals.length>=100){message.textContent='Use 1–80 characters; the board allows 100 goals.';return;}goals.push({id:crypto.randomUUID(),title,stage:'planned'});input.value='';message.textContent='Goal added.';save();render();input.focus();});filter.addEventListener('change',()=>render());render();
</script></body></html>

Trace and verify

Add two identical titles and mark one done for 50 percent. Filter Done and retain 50 percent. Move it back to Planned: show empty-filter feedback and focus the filter. Clear the filter and reload for persistence. Test malformed data and save failures separately.

Common mistakes and boundaries

randomUUID requires a secure context. This local storage design does not merge concurrent tabs, which can overwrite each other. Titles are rendered as text.

Practice challenge

Add one-step undo for deletion, storing the removed record and position. A newer deletion replaces the previous undo opportunity.

Reference solution and reasoning

Restore identity, stage and position only if the ID is absent, then save and render. Explain that this in-memory undo does not survive reload and is not version history.

Project lab: derived progress must use the right population

If a Done filter defines the denominator, the displayed overall completion rate becomes 100% regardless of remaining work. Separate the underlying collection from the visible view and derive statistics explicitly. Stable IDs keep equal-titled goals distinct.

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

function summarize(goals, filter) {
  const visible = filter === 'all' ? goals : goals.filter(g => g.stage === filter);
  const completed = goals.filter(g => g.stage === 'done').length;
  return {visible: visible.length, total: goals.length, completed,
    percent: goals.length ? Math.round(completed / goals.length * 100) : 0};
}
const goals = [
  {id: 'a', title: 'Read', stage: 'done'},
  {id: 'b', title: 'Read', stage: 'todo'},
  {id: 'c', title: 'Practise', stage: 'doing'}
];
console.log(JSON.stringify(summarize(goals, 'done')));
console.log(JSON.stringify(summarize([], 'all')));
console.assert(summarize(goals, 'done').percent === summarize(goals, 'all').percent);

Expected observations

With the done filter, visible=1, total=3, completed=1 and percent=33. All four values are zero for an empty collection.

Why this happens and where it stops applying

Progress is derived state and need not be stored independently, which would risk becoming stale after deletion or migration. Three mutually exclusive stages give this calculation a clear meaning. If paused or cancelled states are added, define their denominator treatment first. Distinguish Showing 1 from Overall 1 of 3. When a stage change removes the focused row from the current filter, restore focus to a stable control.

Extension challenge

Can a weighted roadmap still use completed-count divided by total-count?

Reasoned solution

Validate finite nonnegative weights and divide completed weight by total weight. Define the zero-total case explicitly. Explain the metric in the UI so users do not mistake weighted completion for item completion.

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.