INTERACTIVE COURSE

How Portfolio 60 Works Under the Hood

A UK family investment tracker that converts currencies, fetches live prices, and values your portfolio — all from a single SQLite database and a Bun server. Here's how every piece fits together.

Scroll down to begin
01

What Portfolio 60 Does — And What Happens When You Use It

Start with the product, then trace a real user action into the code.

A Family Investment Dashboard — In Your Browser

Portfolio 60 is a web application that tracks investments for a UK family. It shows how much each person's Trading, ISA, and SIPP accounts are worth — all converted to British pounds.

👥

Multiple People

Tracks investments for each family member separately, with their own accounts.

💷

GBP Valuations

Everything shown in pounds. US and European investments get converted using live exchange rates.

📈

Live Prices

Fetches real prices from Morningstar, Yahoo Finance, and the European Central Bank — automatically.

🔒

Self-Hosted

Runs on your own machine. No cloud accounts, no subscriptions. Your financial data stays yours.

What Happens When You Open the App?

Imagine you type the address into your browser and hit Enter. Here's the journey your request takes — from your browser all the way to the database and back.

1
Your browser sends a request

You type http://localhost:1420. Your browser sends an HTTP request to the server.

2
The server catches it

A Bun server is listening on port 1420. It receives the request and looks at the URL path to decide what to do.

3
The bouncer checks your ID

An auth middleware checks: have you entered the passphrase yet? If not, you get redirected to the passphrase page.

4
The right handler picks up

A router matches your URL to the correct route handler. For the home page, it serves the HTML file directly.

5
The page loads and fetches data

Your browser renders the HTML, then JavaScript on the page calls the API to get your portfolio summary, account details, and latest prices.

The Server's Front Door

This is the actual code that starts the server and handles every single request. It's the first thing that runs.

CODE
const server = Bun.serve({
  port: port,
  hostname: hostname,
  idleTimeout: 0,

  async fetch(request) {
    const url = new URL(request.url);
    const path = url.pathname;

    const authResponse = checkAuth(path);
    if (authResponse) return authResponse;
  }
});
PLAIN ENGLISH

Start a web server using Bun (a fast JavaScript engine)...

Listen on the configured port (1420 by default) and network address...

Never auto-disconnect slow connections — some features stream data in real time...

Every time a request arrives, this function runs...

Figure out what URL the browser is asking for...

Extract just the path part (e.g., "/api/portfolio/summary")...

Ask the bouncer: "Is this person allowed in?"...

If the bouncer says no, send them to the passphrase page immediately.

💡
Every Web App Has a Front Door

This pattern — a single entry point that receives every request and decides where it goes — is how virtually all web servers work. Whether it's a tiny portfolio tracker or Netflix, there's always one function that catches every incoming request first.

The Full Request Journey

Here's the complete path every request takes, from your browser to the database and back.

1

Browser
Sends request

2

Auth Gate
Checks passphrase

3

Router
Matches URL

4

Service
Business logic

5

Database
Reads/writes data

Check Your Understanding

You've just deployed Portfolio 60 and open it in your browser for the first time. You see the passphrase page instead of the home page. Why?

A scheduled task needs to fetch the latest investment prices at 6pm every day — even when nobody is using the app. How does it get past the authentication gate?

02

Meet the Cast — The Components That Run the Show

Know which pieces exist so you can tell AI "put this logic in X, not Y."

A Post Office With Specialist Departments

Think of Portfolio 60 like a post office. Mail arrives at the front desk (the server entry point), gets sorted to the right department, and each department has specialists who know exactly how to handle their type of mail.

The codebase is organised into clear layers. Each layer has one job and talks only to its neighbours.

🚪
Routes

The front desk. Receives HTTP requests, checks what's being asked, and passes work to the right service. Lives in src/server/routes/.

🧠
Services

The specialists. Business logic lives here — calculating portfolio values, orchestrating price fetches, generating reports. Lives in src/server/services/.

🗄️
Database Layer

The filing cabinet. Raw SQL queries that read and write data. One file per table. Lives in src/server/db/.

🌐
Fetchers

The couriers. Go out to external APIs (Morningstar, Yahoo, ECB) to collect prices and exchange rates. Lives in src/server/fetchers/.

🖥️
Frontend (UI)

What you see. HTML pages, JavaScript, and web components that display data and handle user interaction. Lives in src/ui/.

Where Everything Lives

Here's the project's folder structure. Each folder maps to one of the actors above.

src/ All source code
server/ Everything that runs on the server
index.js The front door — starts the server, handles every request
router.js Custom URL matcher — maps paths to handlers
routes/ One file per resource (users, accounts, holdings, fetch...)
services/ Business logic — valuation, scheduling, analysis
db/ Database queries — one file per table
fetchers/ External API clients — Morningstar, Yahoo, ECB
middleware/ Code that runs before route handlers (auth checks)
ui/ Everything the browser sees
index.html The home page
pages/ HTML pages (portfolio, accounts, reports...)
js/ Page-specific JavaScript and web components
css/ Stylesheets (compiled from Tailwind CSS)
shared/ Constants and utilities shared between server and UI

The Switchboard Operator — The Custom Router

Portfolio 60 doesn't use a framework for routing. It has a tiny, hand-built router — just 116 lines of code — that matches incoming URLs to the right handler.

CODE
add(method, path, handler) {
  const paramNames = [];
  const patternString = path.replace(
    /:([a-zA-Z_]+)/g,
    function(match, paramName) {
      paramNames.push(paramName);
      return "([^/]+)";
    }
  );
  const pattern = new RegExp("^" + patternString + "$");
  this.routes.push({ method, pattern, paramNames, handler });
}
PLAIN ENGLISH

Register a new route — tell the router "when you see this URL pattern, use this handler"...

Prepare a list to hold the names of any variable parts in the URL...

Scan the path for placeholders like :id or :userId...

When you find one, remember its name...

...and replace it with a pattern that matches any text (the actual ID value will go here)...

Turn the whole thing into a regular expression — a precise pattern the computer can match against incoming URLs...

Store this route (method + pattern + handler) in the list of all known routes.

💡
URLs Are Just Patterns

When you see /api/users/42/accounts/7, the router sees a pattern: /api/users/:userId/accounts/:accountId. The 42 and 7 are captured as variables the handler can use. This "parameterised routing" pattern is used by every web framework — Express, Django, Rails — Portfolio 60 just built its own tiny version.

How the Layers Talk to Each Other

Watch how a real request flows through the system. When you ask for your portfolio summary, here's the conversation between the components.

Portfolio Summary Request
0 / 8 messages

Check Your Understanding

You want to add a new feature: show the total gain/loss as a percentage. Where would you tell an AI coding tool to put this calculation?

You want to add a new price source — the London Stock Exchange direct feed. Which layer would this new code belong to?

03

The Vault — How Portfolio 60 Stores Your Data

Understand the database so you can debug "where did my data go?" problems.

A Library With a Single Librarian

Most web apps use big database servers like PostgreSQL or MySQL. Portfolio 60 uses SQLite — a database that's just a single file on your hard drive.

Think of it like a private library with one librarian. There's no network, no queue, no separate building. The librarian sits right inside the app and answers queries instantly.

📄

Single File

The entire database is one file: portfolio60.db. Back it up by copying the file. Restore by replacing it.

🔗

Singleton Connection

The app opens the database once and reuses that connection for every query. No connection pools needed.

🔒

WAL Mode

Write-Ahead Logging lets reads and writes happen at the same time without blocking each other.

Opening the Database — Once and Only Once

The singleton pattern ensures the database is opened once and shared everywhere.

CODE
let db = null;

export function getDatabase() {
  if (db) {
    return db;
  }

  db = new Database(dbPath);
  db.exec("PRAGMA journal_mode = WAL");
  db.exec("PRAGMA foreign_keys = ON");
  db.exec("PRAGMA busy_timeout = 5000");

  return db;
}
PLAIN ENGLISH

Start with no database connection...

Every part of the app calls this function when it needs the database...

If we already opened the database, just hand it back — don't open it again...

First time only: open the database file...

Turn on WAL mode so reads and writes don't block each other...

Enforce foreign keys — the database will refuse invalid references...

If the database is busy, wait up to 5 seconds instead of failing immediately...

Return the now-ready database connection.

The Money Trick — Why Everything Is Multiplied by 10,000

Computers are bad at decimal maths. Try 0.1 + 0.2 in JavaScript and you get 0.30000000000000004. That's a disaster when tracking real money.

Portfolio 60's solution: never store decimals. Instead, multiply every number by 10,000 and store it as a whole number. When it's time to display, divide by 10,000.

CODE
export const CURRENCY_SCALE_FACTOR = 10000;

// Storing a price:
const scaledPrice = Math.round(
  priceMinorUnit * CURRENCY_SCALE_FACTOR
);

// Reading a price:
price: row.price / CURRENCY_SCALE_FACTOR
PLAIN ENGLISH

The magic number: multiply by 10,000 to store, divide by 10,000 to display...

When saving a price to the database...

Multiply by 10,000 and round to the nearest whole number. So £123.4567 becomes 1,234,567...

When reading a price back out...

Divide by 10,000 to get back to the real number. 1,234,567 becomes £123.4567 again.

💡
Integers Are Exact, Decimals Are Not

This "scaled integer" pattern is used by banks, payment processors, and financial systems worldwide. Stripe stores amounts in pennies. Portfolio 60 goes four decimal places further to handle exchange rates precisely. When you see financial code storing money as whole numbers, this is why.

Time Travel — How Holdings Remember Their History

When you sell half your shares, the app doesn't just overwrite the old number. It closes the old record with an end date and opens a new one. This means you can ask "what did my portfolio look like last March?" and get the exact answer.

This technique has a name: SCD2 — Slowly Changing Dimension Type 2. It's the same technique used in data warehouses at large companies.

Current holdings: effective_to IS NULL

If a holding has no end date, it's still active. That's what you own right now.

📅
Historic holdings: date falls within the range

effective_from <= date AND (effective_to IS NULL OR effective_to > date) — finds what you held on any past date.

🔐
Unique constraint prevents duplicates

UNIQUE(account_id, investment_id, effective_from) — you can't accidentally create two overlapping records.

Check Your Understanding

A user reports that their portfolio total is off by a fraction of a penny. You look at the database and see prices stored as integers like 1234567. Why does the app store prices this way instead of as normal decimal numbers?

You sold half your shares in June 2024. Now in March 2025, you want to see what your portfolio looked like before the sale. How does the app know?

04

Talking to the Outside World — Fetching Prices and Rates

Know what's external so you can evaluate costs, rate limits, and failure modes.

Three News Wires, One Newsroom

Portfolio 60 gets its data from three external APIs — like a newsroom that subscribes to three different news wires, each specialising in a different type of information.

Morningstar

Investment prices — mutual funds, shares, and investment trusts. The main price source.

💱
Frankfurter (ECB)

Currency exchange rates — how many USD or EUR per £1 GBP. Free, no API key needed. Data from the European Central Bank.

📊
Yahoo Finance

Benchmark indices — FTSE 100, S&P 500, Nasdaq. Used for comparing your portfolio's performance against the market.

Order Matters — Rates First, Then Prices

When the app fetches new data, it always gets currency rates before investment prices. Why? Because a US share price in dollars is meaningless until you know today's USD-to-GBP rate. The rates and prices need to be from the same moment in time.

1

Currency Rates
ECB via Frankfurter

2

Investment Prices
Morningstar API

3

Benchmarks
Yahoo Finance

📌
Contemporaneous Data

In finance, data that's captured at the same time is called "contemporaneous." If your USD exchange rate is from Tuesday but your US share price is from Wednesday, your valuation is subtly wrong. Fetching rates first ensures they're as close in time as possible.

How Currency Rates Are Fetched

This is the actual code that calls the European Central Bank's API to get today's exchange rates.

CODE
const FRANKFURTER_API_URL =
  "https://api.frankfurter.dev/v1/latest";

const symbols = nonGbpCurrencies
  .map(function(c) { return c.code; })
  .join(",");

const url = FRANKFURTER_API_URL
  + "?base=GBP&symbols=" + symbols;
PLAIN ENGLISH

The address of the Frankfurter API — a free service powered by European Central Bank data...

Get all the non-GBP currencies from the database (USD, EUR, etc.)...

Pull out just the currency codes and join them with commas: "USD,EUR"...

Build the full URL: "how many USD and EUR per £1 GBP?"

Watch a Price Fetch in Action

Click through each step to see how the scheduled fetcher collects and stores data.

Scheduler
🧠
Fetch Service
🌐
External APIs
🗄️
Database
Click "Next Step" to begin
Step 0 / 6

It Happens Automatically — Scheduled Fetching

You don't have to click "fetch" every day. A cron job inside the app triggers a full price update on a schedule. If it fails, it retries automatically.

Scheduled Runs

A cron expression sets the fetch time (e.g., every weekday at 6pm). Runs even when nobody is logged in.

🔄

Auto-Retry

If a fetch fails (API timeout, network blip), the scheduler retries with a configurable delay and max attempts.

📚

Historic Backfill

First time? The app fetches 36 months of history — prices, rates, and benchmarks — so you have data from day one.

Check Your Understanding

You add a new US-listed share to the portfolio. The price fetch runs but the currency rate fetch failed earlier. What happens to the valuation?

You've just set up Portfolio 60 for the first time and added your investments. The prices table is completely empty. What happens when the first fetch runs?

05

From Raw Numbers to Your Portfolio — The Valuation Engine

Learn the patterns (scaling, conversion, temporal queries) so you can request them from AI.

The Recipe — How Raw Numbers Become Your Portfolio Value

Imagine a currency exchange desk at an airport. You hand over your foreign currency, they check today's rate, multiply, and give you pounds. Portfolio 60 does this at scale — for every holding, in every account, for every person.

The valuation recipe has three ingredients:

1
Holdings: What you own

Quantity of each investment in each account (e.g., 150 shares of a US tech fund).

2
Prices: What each thing is worth

The latest price per unit from Morningstar (e.g., $45.23 per share).

3
Rates: The exchange rate to GBP

How many units of foreign currency per £1 (e.g., 1 GBP = 1.27 USD).

The Maths — One Line Per Holding

For each holding, the calculation is simple. For a GBP investment, it's just price times quantity. For foreign currency, there's one extra step — divide by the exchange rate.

CODE
const valueLocal = price * holding.quantity;

const valueGBP =
  holding.currency_code === "GBP"
    ? valueLocal
    : valueLocal / ratesMap[holding.currency_code];
PLAIN ENGLISH

Multiply the price by how many units you hold — this gives the value in the investment's own currency...

Now convert to GBP...

If it's already in GBP, the value is ready — no conversion needed...

If it's in a foreign currency (USD, EUR), divide by the exchange rate. Since the rate is "units per GBP", dividing converts foreign currency back to pounds.

💡
Why Divide, Not Multiply?

The exchange rate is stored as "how many foreign units per 1 GBP" (e.g., 1.27 USD per GBP). So to convert $100 to pounds: $100 ÷ 1.27 = £78.74. If the rate were stored the other way ("GBP per 1 USD"), you'd multiply instead. The direction of the rate determines the operation.

Rolling It Up — From Holdings to the Household Total

The service builds a hierarchy: holdings roll up into accounts, accounts into users, users into the household total. Think of it like a spreadsheet with subtotals.

💼

Holdings
Price × Qty → GBP

🏦

Account
Sum holdings + cash

👤

Person
Sum all accounts

🏠

Household
Sum all people

Time Travel Valuations

Remember SCD2 from Module 3? This is where it pays off. You can ask "what was my portfolio worth on 15 June 2024?" and the service will:

1
Find the holdings at that date

Query SCD2 records where 15 June falls within the effective date range.

2
Find the prices on or before that date

Get the most recent price for each investment that's not after 15 June.

3
Find the exchange rates at that date

Use the currency rate from 15 June (or the nearest earlier date) for conversion.

4
Calculate with the same formula

Same multiplication and conversion — just using historical data instead of current data.

Check Your Understanding

You hold 150 shares of a US fund at $45.23 per share. The USD/GBP rate is 1.27. What's the GBP value?

A user wants to see their portfolio value from 6 months ago. How does the app calculate this?

06

What You See — The Frontend That Ties It All Together

Understand how HTML, JavaScript, and web components create the user interface.

No Framework — And That's the Point

Most modern web apps use a frontend framework like React or Vue. Portfolio 60 deliberately doesn't. It uses plain HTML served from the server, with vanilla JavaScript adding interactivity.

Think of it like building furniture from solid wood instead of buying flat-pack. It takes a bit more initial effort, but there are no mysterious framework bugs, no build pipeline to debug, and the code is readable by anyone who knows basic JavaScript.

📄

Server-Rendered HTML

Each page is a real HTML file served from the server. No client-side routing, no virtual DOM.

Vanilla JavaScript

Page-specific JS files fetch data from the API and update the DOM directly. No React, no Vue, no build step.

🧩

Lit Web Components

Reusable pieces (navbar, footer) are built as web components using Lit — a thin library for custom HTML elements.

🎨

Tailwind CSS

Styling uses utility classes — small pre-built CSS snippets you combine directly in the HTML.

How a Page Loads Data

Each page follows the same pattern: the HTML loads, then JavaScript calls the API to fetch data, then renders it into the page.

1
Browser loads the HTML page

The server sends a static HTML file — empty table, loading spinner visible.

2
JavaScript runs on page load

A page-specific JS file (e.g., portfolio.js) calls apiRequest('/api/portfolio/summary').

3
The server returns JSON data

The API handler queries the database through the service layer and returns a JSON response.

4
JavaScript renders the data

The JS loops over the data, creates HTML elements, and inserts them into the page. The spinner disappears.

Custom Building Blocks — Web Components

The navbar and footer appear on every page. Instead of copying HTML, they're built as web components — custom HTML tags like <app-navbar> that the browser knows how to render.

CODE
class AppNavbar extends LitElement {
  createRenderRoot() {
    return this;
  }
  render() {
    return html`
      <nav class="bg-brand-800 text-white">
        <h1>Portfolio 60</h1>
        <ul>...menu items...</ul>
      </nav>
    `;
  }
}
PLAIN ENGLISH

Define a new custom HTML element called AppNavbar, built using Lit (a tiny web component library)...

Use "light DOM" — render directly into the page so Tailwind CSS classes work normally...

(Without this, styles would be isolated inside a shadow boundary and Tailwind wouldn't reach them)...

The render method returns the HTML that this component displays...

A navigation bar with the app title and menu items, styled with Tailwind utility classes.

💡
Light DOM vs Shadow DOM

Web components normally use "Shadow DOM" which isolates their styles completely — great for reusable libraries, but it means external CSS (like Tailwind) can't reach inside. Portfolio 60 uses "light DOM" instead, so the component's HTML is just normal HTML on the page and Tailwind classes work as expected. This is a pragmatic trade-off — less isolation, but simpler styling.

Check Your Understanding

The portfolio page shows stale data — yesterday's prices instead of today's. Where would you look first to debug this?

You ask an AI tool to create a new Lit web component for the app. It looks correct but none of the Tailwind styles are applying. What's the most likely cause?

07

When Things Break — Debugging, Testing, and the Full Picture

Build debugging intuition so you can escape AI bug loops.

Debugging Is Detective Work

When something goes wrong, the key skill isn't knowing the answer — it's knowing where to look. Think of it like a detective following a trail of evidence. Each layer of the app gives you clues, and the error message is the first clue.

Portfolio 60's layered architecture makes this easier. If the symptom is "wrong number on screen," you can trace backwards through the layers to find where the data went wrong.

🖥️
Wrong on the page?

Check the browser's Network tab — is the API returning bad data, or is the JS rendering it wrong?

🧠
Bad API response?

Check the service layer — is the calculation wrong, or is the database returning unexpected values?

🗄️
Database looks wrong?

Check the fetcher — did the external API return bad data, or was it stored incorrectly (scaling issue)?

🌐
External API issue?

Check the fetcher logs — did the API time out, return an error, or change its response format?

Security — The Bouncer's Playbook

Portfolio 60 protects financial data with a passphrase system. But security isn't just about having a password — it's about thinking about all the ways someone could get in without one.

🔐

Hashed Passphrase

The passphrase is never stored in plain text. It's hashed with Argon2, so even if someone reads the database, they can't find the passphrase.

🚫

Brute-Force Protection

After 5 wrong attempts, the account locks for 4 hours. This stops attackers from guessing the passphrase by trying thousands of combinations.

🛡️

Path Traversal Prevention

The file server strips .. from paths and verifies files are inside the allowed directory. This stops attackers from reading files outside the app.

Testing — The Safety Net

Tests are like a checklist a pilot runs before every flight. They catch problems before they reach users. Portfolio 60 has two types.

CODE
// Each test file gets its own database
process.env.DB_PATH =
  "data/portfolio_60_test/test.db";

// Each test file gets its own port
process.env.PORT = "1441";

describe("Holdings DB", () => {
  test("creates a holding", () => {
    const h = createHolding({...});
    expect(h.id).toBeDefined();
  });
});
PLAIN ENGLISH

Give this test its own separate database file — don't touch the real data...

Give this test its own network port — don't clash with the running app or other tests...

Group related tests together under a name...

Define a single test case: "when I create a holding, it should come back with an ID"...

Call the function and check the result. If the ID is missing, the test fails and we know something broke.

💡
Test Isolation Is Everything

Each test file gets its own database and its own port number. This means tests can run in parallel without interfering with each other — and they never touch your real data. This "isolation" principle is why Portfolio 60 can run dozens of tests in seconds with confidence that a pass means the code actually works.

The Full Picture — Everything Connected

Now you've seen every piece. Here's how it all fits together — from the browser at the top to the external APIs at the bottom.

Browser

HTML pages + vanilla JS + Lit web components + Tailwind CSS

↕ HTTP requests / JSON responses
Auth Middleware

Passphrase gate + demo mode block

Router

URL pattern matching → handler dispatch

Services

Portfolio valuation · Fetch orchestration · Scheduled fetcher · Analysis · Reports

Database (SQLite)

Singleton connection · WAL mode · Scaled integers · SCD2 holdings

Fetchers

Morningstar · Frankfurter (ECB) · Yahoo Finance

Debugging in Action

A user reports: "My portfolio total looks way too high." Watch how you'd trace through the layers to find the bug.

Debugging: Inflated Portfolio Total
0 / 5 messages

Final Check

You want to make Portfolio 60 accessible over the internet. What security concern should you raise?

After adding a new investment type, the portfolio total jumps to £2.4 million (it should be around £240). All other investments look correct. What's the most likely bug?

You've Seen Behind the Curtain

You now know how a real web application works — from the browser to the database and back. Every concept here applies to thousands of other apps. Use this knowledge to steer AI tools better, debug smarter, and build with confidence.

You learned

Request flow, layered architecture, scaled integers, SCD2 temporal data, API fetching, currency conversion, web components, and test isolation.

You can now

Read server code, trace data flows, spot scaling bugs, understand database patterns, and direct AI coding tools with precise architectural vocabulary.

×

About This Course

This interactive course was generated from the Portfolio 60 codebase using Codebase to Course by Zara Zhang, powered by Claude AI from Anthropic.

It teaches how the code works through scroll-based modules, animated visualisations, embedded quizzes, and plain-English translations of real code from the project.