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.
Start with the product, then trace a real user action into the code.
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.
Tracks investments for each family member separately, with their own accounts.
Everything shown in pounds. US and European investments get converted using live exchange rates.
Fetches real prices from Morningstar, Yahoo Finance, and the European Central Bank — automatically.
Runs on your own machine. No cloud accounts, no subscriptions. Your financial data stays yours.
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.
You type http://localhost:1420. Your browser sends an HTTP request to the server.
A Bun server is listening on port 1420. It receives the request and looks at the URL path to decide what to do.
An auth middleware checks: have you entered the passphrase yet? If not, you get redirected to the passphrase page.
A router matches your URL to the correct route handler. For the home page, it serves the HTML file directly.
Your browser renders the HTML, then JavaScript on the page calls the API to get your portfolio summary, account details, and latest prices.
This is the actual code that starts the server and handles every single request. It's the first thing that runs.
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;
}
});
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.
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.
Here's the complete path every request takes, from your browser to the database and back.
Browser
Sends request
Auth Gate
Checks passphrase
Router
Matches URL
Service
Business logic
Database
Reads/writes data
Know which pieces exist so you can tell AI "put this logic in X, not Y."
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.
The front desk. Receives HTTP requests, checks what's being asked, and passes work to the right service. Lives in src/server/routes/.
The specialists. Business logic lives here — calculating portfolio values, orchestrating price fetches, generating reports. Lives in src/server/services/.
The filing cabinet. Raw SQL queries that read and write data. One file per table. Lives in src/server/db/.
The couriers. Go out to external APIs (Morningstar, Yahoo, ECB) to collect prices and exchange rates. Lives in src/server/fetchers/.
What you see. HTML pages, JavaScript, and web components that display data and handle user interaction. Lives in src/ui/.
Here's the project's folder structure. Each folder maps to one of the actors above.
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.
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 });
}
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.
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.
Watch how a real request flows through the system. When you ask for your portfolio summary, here's the conversation between the components.
Understand the database so you can debug "where did my data go?" problems.
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.
The entire database is one file: portfolio60.db. Back it up by copying the file. Restore by replacing it.
The app opens the database once and reuses that connection for every query. No connection pools needed.
Write-Ahead Logging lets reads and writes happen at the same time without blocking each other.
The singleton pattern ensures the database is opened once and shared everywhere.
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;
}
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.
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.
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
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.
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.
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.
effective_to IS NULL
If a holding has no end date, it's still active. That's what you own right now.
effective_from <= date AND (effective_to IS NULL OR effective_to > date) — finds what you held on any past date.
UNIQUE(account_id, investment_id, effective_from) — you can't accidentally create two overlapping records.
Know what's external so you can evaluate costs, rate limits, and failure modes.
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.
Investment prices — mutual funds, shares, and investment trusts. The main price source.
Currency exchange rates — how many USD or EUR per £1 GBP. Free, no API key needed. Data from the European Central Bank.
Benchmark indices — FTSE 100, S&P 500, Nasdaq. Used for comparing your portfolio's performance against the market.
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.
Currency Rates
ECB via Frankfurter
Investment Prices
Morningstar API
Benchmarks
Yahoo Finance
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.
This is the actual code that calls the European Central Bank's API to get today's exchange rates.
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;
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?"
Click through each step to see how the scheduled fetcher collects and stores data.
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.
A cron expression sets the fetch time (e.g., every weekday at 6pm). Runs even when nobody is logged in.
If a fetch fails (API timeout, network blip), the scheduler retries with a configurable delay and max attempts.
First time? The app fetches 36 months of history — prices, rates, and benchmarks — so you have data from day one.
Learn the patterns (scaling, conversion, temporal queries) so you can request them from AI.
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:
Quantity of each investment in each account (e.g., 150 shares of a US tech fund).
The latest price per unit from Morningstar (e.g., $45.23 per share).
How many units of foreign currency per £1 (e.g., 1 GBP = 1.27 USD).
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.
const valueLocal = price * holding.quantity;
const valueGBP =
holding.currency_code === "GBP"
? valueLocal
: valueLocal / ratesMap[holding.currency_code];
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.
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.
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
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:
Query SCD2 records where 15 June falls within the effective date range.
Get the most recent price for each investment that's not after 15 June.
Use the currency rate from 15 June (or the nearest earlier date) for conversion.
Same multiplication and conversion — just using historical data instead of current data.
Understand how HTML, JavaScript, and web components create the user interface.
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.
Each page is a real HTML file served from the server. No client-side routing, no virtual DOM.
Page-specific JS files fetch data from the API and update the DOM directly. No React, no Vue, no build step.
Reusable pieces (navbar, footer) are built as web components using Lit — a thin library for custom HTML elements.
Styling uses utility classes — small pre-built CSS snippets you combine directly in the HTML.
Each page follows the same pattern: the HTML loads, then JavaScript calls the API to fetch data, then renders it into the page.
The server sends a static HTML file — empty table, loading spinner visible.
A page-specific JS file (e.g., portfolio.js) calls apiRequest('/api/portfolio/summary').
The API handler queries the database through the service layer and returns a JSON response.
The JS loops over the data, creates HTML elements, and inserts them into the page. The spinner disappears.
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.
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>
`;
}
}
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.
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.
Build debugging intuition so you can escape AI bug loops.
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.
Check the browser's Network tab — is the API returning bad data, or is the JS rendering it wrong?
Check the service layer — is the calculation wrong, or is the database returning unexpected values?
Check the fetcher — did the external API return bad data, or was it stored incorrectly (scaling issue)?
Check the fetcher logs — did the API time out, return an error, or change its response format?
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.
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.
After 5 wrong attempts, the account locks for 4 hours. This stops attackers from guessing the passphrase by trying thousands of combinations.
The file server strips .. from paths and verifies files are inside the allowed directory. This stops attackers from reading files outside the app.
Tests are like a checklist a pilot runs before every flight. They catch problems before they reach users. Portfolio 60 has two types.
// 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();
});
});
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.
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.
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.
HTML pages + vanilla JS + Lit web components + Tailwind CSS
Passphrase gate + demo mode block
URL pattern matching → handler dispatch
Portfolio valuation · Fetch orchestration · Scheduled fetcher · Analysis · Reports
Singleton connection · WAL mode · Scaled integers · SCD2 holdings
Morningstar · Frankfurter (ECB) · Yahoo Finance
A user reports: "My portfolio total looks way too high." Watch how you'd trace through the layers to find the bug.
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.
Request flow, layered architecture, scaled integers, SCD2 temporal data, API fetching, currency conversion, web components, and test isolation.
Read server code, trace data flows, spot scaling bugs, understand database patterns, and direct AI coding tools with precise architectural vocabulary.