Courses Learn Blog Contact
Get Started →
Lesson 15

Day 15: Project 3 - Build a Product

The second solo project: choose a product, write a one-page PRD with an honest out-of-scope list, and ship something with state, storage, validation and accessibility.

Webbo3 Level 2: JavaScript Development 12 min read Free

Webbo3 Level 2 · JavaScript Development · Day 15 of 20

Project 3: Build a Product

A solo build that belongs in your portfolio, and a page of planning before a single line of code.

By the end of today you can

  • Write a short PRD that turns a vague idea into a scoped build
  • Choose your own product and defend the choice
  • Ship something with state, storage, real UX and accessibility
  • Explain the trade-offs you made and what you deliberately left out

Project 1 asked whether you could make a page work. This one asks whether you can build a product: decide what it is, decide what it is not, and finish it.

The deciding is the hard part, and it is why today starts with writing rather than typing.

1. Choose Your Product

Definition

A product is software someone would use again tomorrow: it holds records they create, works something out from them, and helps them find what they need. A page shows things; a product keeps and works on them.

Real-Life Example

A notebook where a market trader writes each sale is a page. The same notebook with a running total at the bottom of every page, and the unpaid ones circled in red, is a product.

Types: Pick One

ProductThe interesting problem in it
Personal finance trackerCategories, monthly totals, a chart you draw yourself from divs
Job application trackerStages, dates, "no reply in 14 days" logic
Student management dashboardRecords, grades, filtering, an average that must always be right
Expense splitterWho owes whom, which is genuinely fiddly arithmetic
Event managerDates, capacity, "sold out" and past-event states

Or propose your own, provided it has three things: records the user creates, at least one derived value (a number worked out from the records) that must stay correct, and something worth filtering.

Picture This

Picture the trader's notebook with the running total and the red circles. That is the difference between a page and a product.

2. Write the PRD First

Definition

A PRD (Product Requirements Document) is one page that says what problem you are solving, for whom, what you will build, and what you will not build. It is not corporate paperwork. It is the page that stops you building the wrong thing for six hours.

Real-Life Example

A builder does not start laying blocks and decide on the number of rooms later. The drawing comes first, and the drawing also says there is no swimming pool, so nobody digs one halfway through.

Types: The Sections

SectionAnswers
ProblemWhat is wrong today, in plain words
Who it is forOne real kind of person
The one thing it must do wellIf it does only this, it is still worth using
Core featuresWhat this build includes
Out of scopeWhat it deliberately does not include, and why
User stories"As a ..., I can ..." for each thing a user does
How I will know it is doneChecks anyone can run

Write it before you open your editor, and keep it in the repository as PRD.md. Lines starting with # are headings and lines starting with - are bullet points: that is Markdown, the same format as your README.

What this code doesWe want a complete PRD for one of the five products, short enough to write in 45 minutes, so you can copy its shape for your own.
PRD.md
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Job Application Tracker

## Problem
I apply to a lot of roles and lose track of which I have heard back from,
which need chasing, and which are dead. Right now this lives in my head and
in a WhatsApp message to myself.

## Who it is for
Me, and anybody job hunting who does not want a spreadsheet.

## The one thing it must do well
Tell me, at a glance, which applications need action today.

## Core features (this build)
- Add an application: company, role, date applied, link, status
- Statuses: applied, interviewing, offer, rejected
- Mark an application as needing follow-up if applied 14 or more days ago
  and still "applied"
- Filter by status, search by company
- Everything persists across a refresh

## Out of scope (deliberately)
- Accounts and login. One person, one browser.
- Email reminders. There is no backend.
- File uploads for CVs.

## User stories
- As a job hunter, I can add an application in under 15 seconds
- As a job hunter, I can see which applications are stale without reading dates
- As a job hunter, I can find a company by typing three letters

## How I will know it is done
- I can add 10 applications, close the browser, reopen, and see all 10
- The stale badge appears on exactly the right rows, tested at 13, 14 and 15 days
- Lighthouse Accessibility above 95
- It is usable on my phone
NOTE   
GitHub shows PRD.md as a formatted page at the root of your repository.
The Out-of-Scope Section Is the Most Valuable Part

It is the section that stops you at 4pm adding login "quickly". Write down three things you are deliberately not building and why. In the assessment you will be asked about them, and "I decided that was out of scope because..." is a much better answer than a half-finished login screen.

Turning "Done" Into a Test

The PRD says the stale badge is "tested at 13, 14 and 15 days". Here is what that means in code. A date's getTime() is the number of milliseconds since 1970, so subtracting two of them gives the gap in milliseconds. A day is 24 × 60 × 60 × 1000 = 86,400,000 milliseconds. We pass "today" in as an argument, so the test gives the same answer whichever day it runs.

What this code doesWe want to prove the PRD's "done" check for the stale badge: the day before the limit, the limit itself, the day after, and an old application that is already moving.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Same check function as Day 14
function check(description, actual, expected) {
  const pass = JSON.stringify(actual) === JSON.stringify(expected);
  if (pass) {
    console.log(`PASS  ${description}`);
  } else {
    console.log(`FAIL  ${description}`);
    console.log(`      expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
  }
}

const DAY_MS = 24 * 60 * 60 * 1000;

// Whole days between two dates
function daysBetween(earlier, later) {
  return Math.floor((later.getTime() - earlier.getTime()) / DAY_MS);
}

// Stale: still "applied" and applied 14 or more days ago
function isStale(application, today) {
  if (application.status !== 'applied') return false;
  return daysBetween(new Date(application.applied), today) >= 14;
}

const today = new Date('2026-03-15');

check('13 days: not yet', isStale({ status: 'applied', applied: '2026-03-02' }, today), false);
check('14 days: stale', isStale({ status: 'applied', applied: '2026-03-01' }, today), true);
check('15 days: stale', isStale({ status: 'applied', applied: '2026-02-28' }, today), true);
check('old but interviewing', isStale({ status: 'interviewing', applied: '2026-01-10' }, today), false);
OUTPUT   TERMINAL
➜ webbo3 node app.js
PASS  13 days: not yet
PASS  14 days: stale
PASS  15 days: stale
PASS  old but interviewing
Try This

Write your own PRD now, before anything else. Then take one line from "How I will know it is done" and write it as check() calls, the way the stale badge was written above.

Picture This

Picture the builder's drawing: it says where every room goes, and it says in writing that there is no swimming pool.

3. What It Must Include

#RequirementFrom
1Create, edit and delete recordsDays 3, 4
2One state object, and render from itDay 11
3Persists in localStorage, and survives corrupt dataDay 11
4Search and at least one filterDay 9
5At least one derived value that is always correctDay 11
6Split into modulesDay 6
7Form validation with messages that say what to doDay 13
8Full keyboard operation and a visible focus ringDay 13
9Empty state, and a confirmation before destructive actionsDay 9
10Works at phone widthMonth 1

The Derived Value: Where the Marks Are

A derived value is a number worked out from your records: a total, an average, a count of what is overdue. It must be calculated at render time, never stored. The assessor will add a record, delete one and edit one, and watch whether the number stays correct. This is the single most common place these projects fail.

Here is the failure happening. This tracker keeps a running total in its state and adds to it on every new expense, but the delete button forgets to take anything away.

What this code doesWe want to watch a stored total go wrong. We add rent and data, then delete the data bundle.
index.html
1
2
3
4
5
6
7
<form id="add">
  <input id="label" placeholder="What" value="">
  <input id="amount" type="number" placeholder="Amount (₦)">
  <button>Add</button>
</form>
<ul id="list"></ul>
<p id="total"></p>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// The total is STORED in state and updated by hand
const state = { expenses: [], total: 0 };

function render() {
  document.querySelector('#list').innerHTML = state.expenses
    .map((item, index) =>
      `<li>${item.label}: ₦${item.amount} <button data-index="${index}">Delete</button></li>`)
    .join('');
  document.querySelector('#total').textContent = `Total: ₦${state.total}`;
}

// Add: push the expense and add to the stored total
document.querySelector('#add').addEventListener('submit', (event) => {
  event.preventDefault();
  const amount = Number(document.querySelector('#amount').value);
  state.expenses.push({ label: document.querySelector('#label').value, amount });
  state.total += amount;
  render();
});

// Delete: remove the expense, and forget the total
document.querySelector('#list').addEventListener('click', (event) => {
  if (!event.target.dataset.index) return;
  state.expenses.splice(Number(event.target.dataset.index), 1);
  render();
});
BROWSERindex.html · after adding Rent and Data, then deleting Data

One expense of ₦3000 on screen, and a total of ₦3500. Every place that changes the list must also remember to fix the total, and one forgot. The cure is not to remember harder; it is to stop storing the total at all and work it out from the list with reduce (Day 3) every time you render.

What this code doesWe want the same clicks on a version that calculates the total inside render(), so it cannot disagree with the list.
index.html
1
2
3
4
5
6
7
<form id="add">
  <input id="label" placeholder="What" value="">
  <input id="amount" type="number" placeholder="Amount (₦)">
  <button>Add</button>
</form>
<ul id="list"></ul>
<p id="total"></p>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Only the records are stored; the total is not
const state = { expenses: [] };

function render() {
  document.querySelector('#list').innerHTML = state.expenses
    .map((item, index) =>
      `<li>${item.label}: ₦${item.amount} <button data-index="${index}">Delete</button></li>`)
    .join('');

  // Derived: worked out from the current list on every render
  const total = state.expenses.reduce((sum, item) => sum + item.amount, 0);
  document.querySelector('#total').textContent = `Total: ₦${total}`;
}

document.querySelector('#add').addEventListener('submit', (event) => {
  event.preventDefault();
  const amount = Number(document.querySelector('#amount').value);
  state.expenses.push({ label: document.querySelector('#label').value, amount });
  render();
});

document.querySelector('#list').addEventListener('click', (event) => {
  if (!event.target.dataset.index) return;
  state.expenses.splice(Number(event.target.dataset.index), 1);
  render();
});
BROWSERindex.html · after the same clicks
Search Your Code for This

If your total or count ever appears on the left of an = outside render(), you have stored it. That line is where the wrong number comes from. Delete it and calculate the value in render().

Surviving Rubbish in Storage

Requirement 3 says the app must survive corrupt data. The assessor will open DevTools, type rubbish into your storage key and reload. JSON.parse throws on anything that is not valid JSON, so the load function catches that and starts fresh, as on Day 11.

What this code doesWe want to see the app survive what the assessor does: with one expense already saved, overwrite the storage key with rubbish in DevTools, then reload.
index.html
1
2
<p id="status"></p>
<ul id="list"></ul>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const KEY = 'tracker.expenses';

// Load saved expenses; start empty if nothing is saved or it is not valid JSON
function load() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY));
    if (!Array.isArray(saved)) return [];
    return saved;
  } catch (error) {
    console.warn('Saved data was unreadable, starting fresh:', error.name);
    return [];
  }
}

const expenses = load();
document.querySelector('#status').textContent = `${expenses.length} saved expenses`;
document.querySelector('#list').innerHTML =
  expenses.map((item) => `<li>${item.label}: ₦${item.amount}</li>`).join('');
BROWSERindex.html · after putting {rubbish in the key and reloading
CONSOLE   BROWSER DEVTOOLS
Saved data was unreadable, starting fresh: SyntaxError

A Chart Without a Library

A bar chart is a row of divs, each given a width as a percentage of the biggest value. Math.max(...values) finds the biggest: the three dots spread the array into separate numbers, which is what Math.max expects.

What this code doesWe want a spending-by-category chart built only from divs and widths, to show that "draw the chart yourself" is about ten lines.
index.html
1
<div id="chart"></div>
style.css
1
2
3
4
#chart { font-family: sans-serif; }
.row { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
.name { width: 70px; }
.bar { background: #3b8eea; height: 18px; }
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const spending = [
  { category: 'Food', amount: 42000 },
  { category: 'Transport', amount: 18000 },
  { category: 'Data', amount: 9000 },
];

// The biggest amount becomes a full-width bar
const biggest = Math.max(...spending.map((item) => item.amount));

document.querySelector('#chart').innerHTML = spending
  .map((item) => {
    const percent = Math.round((item.amount / biggest) * 100);
    return `<div class="row">
      <span class="name">${item.category}</span>
      <div class="bar" style="width: ${percent}%"></div>
      <span>₦${item.amount}</span>
    </div>`;
  })
  .join('');
BROWSERindex.html · the chart as drawn
Picture This

Picture the trader's running total written in pencil and rubbed out whenever a sale is cancelled. One forgotten rub-out and every total after it is wrong. Adding up the page each time is slower to write and never wrong.

4. How to Spend the Day

TimeDoingNot doing
First 45 minThe PRD. Sketch the screen on paper.Opening your editor
Next 90 minData model and state. Get add and list working, ugly.Any CSS at all
MiddleEdit, delete, filter, search, the derived value.Polishing
NextStorage, empty state, validation.New features
Last 90 minAccessibility audit, phone width, README, deploy.Anything new
Final 20 minDelete dead code. Rename anything vague.Adding "one more"
The Trap That Catches Half a Cohort

Styling at hour two. It is the comfortable, familiar work you did last month, and it feels like progress. Then at 4pm the delete button does not work and there is no time. Ugly and finished beats beautiful and broken, every time, and it is marked that way.

5. The Rubric

CriterionMarksWhat is looked at
PRD and scope15Is the problem real, is the scope sane, is there an honest out-of-scope list
It works25All ten requirements, tested by somebody who did not build it
State and correctness20One source of truth, derived values that stay right under editing and deleting
Code quality15Modules, naming, no repeated blocks, no dead code
UX and accessibility15Keyboard, focus, empty states, validation messages, phone width
Explanation10Can you justify your scope and your trade-offs

The Questions You Will Be Asked

  • Show me the derived value. Now delete a record while I watch.
  • What did you leave out, and what would you build next?
  • What happens if I open DevTools and put rubbish in your storage key?
  • Which part of this took longest, and was it the part you expected?
  • Show me a function you are not happy with, and tell me why.
That last question feels like a trap. Is it?

No, it is the opposite. Every codebase has a function its author is not happy with, and being able to point at it and say why shows you have standards and know where you fell short of them. "There isn't one" is the weak answer: it says either you have not read your own code or you cannot yet tell good from bad.

Hand In

  • A GitHub repository, with PRD.md at the root and a commit history that shows the order you built things in.
  • A README: what it is, a screenshot, how to run it, what is out of scope, what you would do next.
  • A deployed link on GitHub Pages, Netlify or Vercel. It takes ten minutes, and from Day 18 it is compulsory anyway.

Do This Now

  1. Choose your product and write PRD.md, including three out-of-scope items.
  2. Sketch the one screen on paper.
  3. Build add and list with no CSS, then edit, delete, filter and search.
  4. Calculate your derived value inside render(), then add, edit and delete while you watch it.
  5. Add storage and put rubbish in the key to prove the app survives.
  6. Audit keyboard and phone width, write the README, deploy, and hand in.

Questions You Will Have Today

My idea is too ambitious. Do I change it or cut it down?

Cut it down, and write the cut into the out-of-scope section. Changing your idea at 11am costs you the whole morning. Shrinking it costs one paragraph, and "I scoped this down when I realised the date logic was the hard part" is a genuinely good thing to say in the assessment.

Should I use a library for charts, dates or storage?

Not this week. The point of this project is that you can do these things yourself, and you know from Day 12 that the browser does more than beginners assume. A bar chart is a row of divs with a width. A date is Intl. Storage is four methods. Reach for a library in Level 3, when you can say what it is saving you.

My derived value goes wrong after I delete something. Where do I look?

You stored it instead of calculating it. Search for anywhere the total or count is assigned outside render(). That is almost certainly the line. Delete it and compute the value in render() from the current state.

How much CSS is enough?

Readable, spaced, works at phone width, visible focus ring. That is the bar, and it is fifteen marks shared with UX. There are no marks for a colour palette. If your validation messages still say "Invalid" at 4pm, that is where the next hour goes, not into shadows.

Tick Before You Move On

  • ☐ My PRD was written before any code and is in the repository
  • ☐ All ten requirements work, checked by somebody else
  • ☐ The derived value stays correct after adding, editing and deleting
  • ☐ Corrupt storage does not break the app
  • ☐ Keyboard-only operation works and Lighthouse Accessibility is above 95
  • ☐ I deleted dead code and renamed everything vague before submitting

Quick Recap

Decide what it is not, in writing, before you decide what it is. Data and behaviour first; CSS last, and only when it works. The derived value must be calculated, never stored. Rubbish in storage must never break the app. Ugly and finished beats beautiful and broken.

Final Scenario

At the assessment, the assessor adds three records, deletes the middle one, edits the last, and looks at your total. It is right. They open DevTools, type rubbish into your key and reload. The app opens empty and calm.

Then they ask what you left out. You open PRD.md and read them the three lines you wrote at 9am, with the reason for each.

That is what building a product means: not the most features, but the right ones, finished.

Next week: the professional week. AI you supervise, security, shipping, the job hunt, and a final team build for a real client.

Learn this with a real instructor

Free lessons take you a long way on your own. The full programmes add live classes, assignments, Skill Points and a certificate — built for African students.