Metals Price Tracker PWA
Build a live metals price dashboard installable as an Android app.
Introduction
30 Second Summary
Precious metals prices change every minute. Traders, investors, and curious minds want a quick glance at current spot prices without digging through cluttered financial websites.
In this project, you will build a live metals price dashboard that fetches real gold, silver, copper, platinum, and palladium prices from a free API. You will also turn it into a Progressive Web App that installs on Android phones like a native app.
What You'll Build
A dark-themed, responsive web dashboard that displays live spot prices for five precious metals, auto-refreshes every 60 seconds, and works as an installable Android app.
By the end of this project, you'll have:
- A responsive dashboard with a dark theme and card-based layout displaying live metal prices from the MetalMetric API.
- Live API integration that auto-refreshes every 60 seconds with price direction indicators showing whether each metal moved up or down.
- A PWA manifest and service worker that makes your web app installable on Android and accessible from any device on your local network.
- Secret Mission: Add a Gold/Silver Ratio card with a buy-signal zone indicator.
Are there any prerequisites?
Basic familiarity with HTML, CSS, and JavaScript is helpful. You do not need experience with APIs, Progressive Web Apps, or mobile development.
Set Up the Project
Before you can display live metals prices, you need a project structure with the right files in place.
In this step, you will create the folder, files, and placeholder icons that make up your Progressive Web App. You will also configure Live Server so any device on your WiFi network can access the dashboard.
In this step, get ready to:
- Create the project folder and all required files.
- Create placeholder app icons for PWA installability.
- Verify Live Server serves your project across the local network.
Create the project folder structure
Every file in your finished app has a specific job. You will create them all now so VS Code can track the full project from the start.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir metals-tracker
cd metals-tracker
- Create the project files by running these commands:
macOS
touch index.html style.css app.js manifest.json sw.js
Windows
New-Item index.html, style.css, app.js, manifest.json, sw.js
- Create the icons folder by running:
mkdir icons
- Confirm the files exist by running:
ls
You should see app.js, icons, index.html, manifest.json, style.css, and sw.js in the output.
- Open the metals-tracker folder in VS Code by running:
code .
What does each file do?
- index.html is the page structure (header, cards, footer).
- style.css handles the dark theme and responsive grid layout.
- app.js fetches live prices from the API and updates the page.
- manifest.json tells the browser this is an installable app.
- sw.js is the service worker that caches files for offline use.
Don't see all the files?
Make sure you ran the file-creation command from inside the metals-tracker folder. If you accidentally ran it from the Desktop, delete the stray files and re-run after cd metals-tracker.
help me figure out why my project files are missing
Create app icons for the PWA
Android requires two icon sizes (192x192 pixels and 512x512 pixels) before it will let users install a web app to their home screen. You will create simple colored-square placeholders now.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Paint and press Enter to open it.
- Press Ctrl+E to open the Image properties dialog.
- Set the Width to 192 and the Height to 192.
- Make sure Pixels is selected as the unit, then click OK.
- Select the Fill tool (the paint bucket icon in the toolbar).
- Pick a gold/amber colour from the colour palette.
- Click anywhere on the canvas to fill the entire square with the colour.
- Click File then Save as then PNG picture.
- Navigate to the icons folder inside metals-tracker on your Desktop.
- Name the file icon-192 and click Save.
Now repeat for the 512px icon.
- Press Ctrl+E again to open Image properties.
- Change Width to 512 and Height to 512, then click OK.
- Fill the canvas with the same colour using the Fill tool.
- Click File then Save as then PNG picture.
- Save it in the same icons folder with the name icon-512.
Why placeholders instead of real icons?
Chrome on Android checks that both a 192px and 512px icon exist in the manifest before allowing installation. The content of the image does not matter for installability. You can swap these for polished icons later.
- Back in VS Code, confirm the icons exist by running this in the terminal:
ls icons
You should see icon-192.png and icon-512.png listed.
Icons folder is empty?
Double-check that you saved the files into the icons folder inside metals-tracker on your Desktop. Paint's Save dialog sometimes defaults to the Documents folder.
help me find where my icon files saved
macOS alternative
On macOS, open Preview, click File > New from Clipboard, then Tools > Adjust Size to set 192x192 pixels. Fill with a colour and export as PNG. Repeat at 512x512.
Verify Live Server works on the local network
By default, Live Server only listens on your machine. To access the dashboard from a phone or tablet on the same WiFi, you need to enable network access in the settings.
- In VS Code, open Settings by pressing Cmd+, (macOS) or Ctrl+, (Windows).
- Type liveServer.settings.useLocalIp in the search bar at the top.
- Check the box next to Live Server > Settings: Use Local Ip.
What does this setting do?
When enabled, Live Server binds to 0.0.0.0 instead of 127.0.0.1. This makes the server accessible on every network interface your computer has, including WiFi.
- In the VS Code Explorer sidebar (left panel), right-click index.html.
- Click Open with Live Server.
Your default browser opens with an address like http://192.168.x.x:5500. The page will be blank because index.html is empty. That is expected.
- Note the IP address shown in the browser address bar (for example 192.168.1.42).
- On your phone (connected to the same WiFi), open Chrome and type the full address including the port, like http://192.168.1.42:5500.
You should see the same blank page load on your phone. This confirms the network connection works.
Phone can't connect?
Make sure both your computer and phone are on the same WiFi network. If you are using a VPN on either device, disconnect it temporarily.
On Windows, you may see a firewall prompt when Live Server starts. Click Allow access to let traffic through on port 5500.
help me troubleshoot why my phone cannot reach Live Server
Your project skeleton is ready and accessible from any device on your network. Next up, you will build the dashboard layout with a dark theme and responsive card grid.
Build the Dashboard Layout
Your project folder is set up with all the files you need. Now it's time to build the visual dashboard that displays live metal prices.
In this step, you'll create the HTML structure for the price cards, style everything with a dark theme and responsive grid, and add loading animations so users see smooth placeholders while data loads.
In this step, get ready to:
- Create the HTML structure with a header, five metal price cards, and a footer.
- Style the dashboard with a dark theme and responsive CSS Grid layout.
- Add loading skeleton animations and price change indicator styles.
Create the HTML structure
Every web app starts with HTML that defines what content appears on the page. Your dashboard needs a header, five metal price cards, and a footer.
- In VS Code, open index.html from the metals-tracker folder.
- Paste the following page structure into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1a1a2e">
<title>Metals Price Tracker</title>
<link rel="stylesheet" href="style.css">
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
</head>
<body>
<div class="app">
<header class="header">
<h1 class="header__title">Metals Tracker</h1>
<div class="header__status">
<span class="header__updated" id="lastUpdated">Loading...</span>
<button class="header__refresh-btn" id="refreshBtn" aria-label="Refresh prices">↻</button>
</div>
</header>
<div class="error-message" id="errorMessage" hidden>
<p>Unable to fetch prices. Will retry automatically.</p>
</div>
<main class="grid" id="priceGrid">
</main>
<footer class="footer">
<p class="footer__countdown" id="countdown">Next refresh in 60s</p>
<p class="footer__attribution">Data: <a href="https://metalmetric.com" target="_blank" rel="noopener">MetalMetric</a> (COMEX/LBMA)</p>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>
What does this code do?
- The <meta name="viewport"> tag makes the page scale properly on mobile devices.
- The <meta name="theme-color"> tag sets the browser toolbar color on Android to match the dark background.
- The <main class="grid"> element is empty for now. You will fill it with metal price cards next.
- The footer shows a countdown timer and credits MetalMetric as the data source.
- Save index.html.
- Refresh your browser. You should see unstyled text including "Metals Tracker", "Loading...", "Next refresh in 60s", and the MetalMetric attribution link.
Don't see any text in the browser?
- Make sure Live Server is still running. Right-click index.html and select Open with Live Server if needed.
- Check that you saved the file. Unsaved changes won't appear in the browser.
Why isn't my HTML showing in the browser?
Now add the five metal price cards inside the grid. Each card shows a chemical symbol, metal name, price display area, unit, and a change indicator.
- In index.html, find the empty <main class="grid" id="priceGrid"> element.
- Paste the following five card elements between the opening and closing <main> tags:
<div class="card card--gold" data-metal="gold">
<div class="card__header">
<span class="card__icon">Au</span>
<span class="card__name">Gold</span>
</div>
<div class="card__price" id="price-gold">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / oz t</span>
<span class="card__change" id="change-gold">-</span>
</div>
</div>
<div class="card card--silver" data-metal="silver">
<div class="card__header">
<span class="card__icon">Ag</span>
<span class="card__name">Silver</span>
</div>
<div class="card__price" id="price-silver">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / oz t</span>
<span class="card__change" id="change-silver">-</span>
</div>
</div>
<div class="card card--copper" data-metal="copper">
<div class="card__header">
<span class="card__icon">Cu</span>
<span class="card__name">Copper</span>
</div>
<div class="card__price" id="price-copper">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / lb</span>
<span class="card__change" id="change-copper">-</span>
</div>
</div>
<div class="card card--platinum" data-metal="platinum">
<div class="card__header">
<span class="card__icon">Pt</span>
<span class="card__name">Platinum</span>
</div>
<div class="card__price" id="price-platinum">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / oz t</span>
<span class="card__change" id="change-platinum">-</span>
</div>
</div>
<div class="card card--palladium" data-metal="palladium">
<div class="card__header">
<span class="card__icon">Pd</span>
<span class="card__name">Palladium</span>
</div>
<div class="card__price" id="price-palladium">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / oz t</span>
<span class="card__change" id="change-palladium">-</span>
</div>
</div>
What does this code do?
- Each card uses a data-metal attribute so JavaScript can identify which metal the card represents.
- The card__skeleton span is a placeholder that will show a pulsing animation while prices load.
- Copper uses "USD / lb" (per pound) while the other four metals use "USD / oz t" (per troy ounce).
- The card__change element will display up/down arrows once the JavaScript compares prices between refreshes.
- Save index.html.
- Refresh your browser. You should see the text "Au Gold", "Ag Silver", "Cu Copper", "Pt Platinum", and "Pd Palladium" listed on the page (still unstyled).
Only see the header but no metal names?
- Make sure you pasted the cards between the <main> opening and closing tags, not outside them.
- Check for missing closing </div> tags. Each card has four nested divs that all need to be closed.
My metal cards aren't showing up in the browser.
Style the dashboard with a dark theme and responsive grid
With the HTML structure in place, you'll now add CSS that transforms the raw text into a dark-themed, touch-friendly dashboard.
- Open style.css in VS Code.
- Paste the following theme variables, reset, and header styles:
:root {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--bg-card-hover: #1c2a4a;
--text-primary: #eaeaea;
--text-secondary: #a0a0b0;
--border-color: #2a2a4a;
--accent-gold: #ffd700;
--accent-silver: #c0c0c0;
--accent-copper: #b87333;
--accent-platinum: #7b8fa2;
--accent-palladium: #5ba8a0;
--change-up: #4caf50;
--change-down: #f44336;
--radius: 16px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
}
.app {
max-width: 900px;
margin: 0 auto;
padding: 20px 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 12px;
}
.header__title {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.header__status {
display: flex;
align-items: center;
gap: 12px;
}
.header__updated {
font-size: 0.85rem;
color: var(--text-secondary);
}
.header__refresh-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-primary);
font-size: 1.3rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.2s;
}
.header__refresh-btn:active {
transform: scale(0.92);
background: var(--bg-card-hover);
}
.header__refresh-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.error-message {
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 20px;
color: #f44336;
font-size: 0.9rem;
}
What does this code do?
The :root block defines CSS custom properties (variables) for every color in the app. This makes it easy to tweak the entire theme from one place.
The refresh button is sized at 44px by 44px. This is the minimum recommended tap target size for touch-friendly interfaces.
The .error-message container is hidden by default (the HTML has hidden on it) and only appears when a fetch fails.
- Save style.css and refresh your browser.
- You should see a dark navy background with light text. The header shows "Metals Tracker" on the left and a round refresh button on the right.
Still seeing a white background?
- Check that your index.html has <link rel="stylesheet" href="style.css"> in the <head> section.
- Confirm style.css is in the same folder as index.html (both inside metals-tracker).
My CSS file isn't being applied to the page.
Next, add the responsive grid and card styles. These make the cards arrange into columns based on screen width.
- Add the following CSS below the .error-message block in style.css:
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 500px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
.card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px;
border: 1px solid var(--border-color);
box-shadow: var(--shadow);
transition: transform 0.2s, border-color 0.3s;
min-height: 140px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card:active {
transform: scale(0.98);
}
.card--gold { border-top: 3px solid var(--accent-gold); }
.card--silver { border-top: 3px solid var(--accent-silver); }
.card--copper { border-top: 3px solid var(--accent-copper); }
.card--platinum { border-top: 3px solid var(--accent-platinum); }
.card--palladium { border-top: 3px solid var(--accent-palladium); }
.card__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.card__icon {
font-size: 1.1rem;
font-weight: 700;
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
}
.card--gold .card__icon { color: var(--accent-gold); }
.card--silver .card__icon { color: var(--accent-silver); }
.card--copper .card__icon { color: var(--accent-copper); }
.card--platinum .card__icon { color: var(--accent-platinum); }
.card--palladium .card__icon { color: var(--accent-palladium); }
.card__name {
font-size: 0.95rem;
color: var(--text-secondary);
font-weight: 500;
}
.card__price {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 8px;
min-height: 40px;
display: flex;
align-items: center;
}
.card__footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.card__unit {
font-size: 0.75rem;
color: var(--text-secondary);
}
.card__change {
font-size: 0.9rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
}
What does this code do?
The .grid uses CSS Grid with media queries to create a responsive layout. On phones (under 500px) cards stack in one column. On tablets (500px+) they show two columns. On desktops (768px+) they show three columns.
Each metal card gets a unique colored top border via modifier classes like .card--gold. Gold is amber, silver is gray, copper is orange-brown, platinum is blue-gray, and palladium is teal.
- Save style.css and refresh your browser.
- You should see five dark cards arranged in a grid, each with a distinctly colored top border. The gold card has an amber border, silver has a gray border, and so on.
Cards showing in a single column on desktop?
- Make sure your browser window is wider than 768px. Resize the window to see the grid columns adjust.
- Verify the @media queries are outside the .grid block, not nested inside it.
My grid layout isn't responding to screen width changes.
Add loading skeleton and indicator styles
While the app waits for price data from the API, users should see a smooth pulsing animation instead of empty space. You will also add styles for the price change indicators that show whether a price went up or down.
- Add the following CSS at the bottom of style.css, below the .card__change block:
.card__change--up {
color: var(--change-up);
background: rgba(76, 175, 80, 0.1);
}
.card__change--down {
color: var(--change-down);
background: rgba(244, 67, 54, 0.1);
}
.card__skeleton {
display: block;
width: 120px;
height: 28px;
background: linear-gradient(90deg, var(--border-color) 25%, var(--bg-card-hover) 50%, var(--border-color) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 6px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes flash {
0% { background-color: var(--bg-card); }
50% { background-color: var(--bg-card-hover); }
100% { background-color: var(--bg-card); }
}
.card--updated {
animation: flash 0.6s ease;
}
.footer {
margin-top: 32px;
text-align: center;
color: var(--text-secondary);
font-size: 0.8rem;
}
.footer__countdown {
margin-bottom: 4px;
}
.footer a {
color: var(--text-secondary);
text-decoration: underline;
}
What does this code do?
The .card__skeleton creates a loading placeholder with a shimmer animation. The gradient slides across the element continuously, giving the visual effect of content loading.
The .card__change--up and .card__change--down classes will be applied by JavaScript later to show green (price up) or red (price down) indicators.
The flash keyframe animation briefly highlights a card when its price changes, drawing the user's attention to the update.
- Save style.css and refresh your browser.
- You should see a pulsing gray shimmer animation inside each card where the price will eventually appear. The footer text should now be centered at the bottom.
Don't see the shimmer animation?
- Verify your HTML has <span class="card__skeleton"></span> inside each card__price div.
- Check that the @keyframes shimmer block is not accidentally nested inside another CSS rule.
The skeleton loading animation isn't appearing in my cards.
✔️ Awesome, I've got everything!
Great. Double check you have saved both index.html and style.css.
ⓧ I'd like to double check the full code
Here is the complete index.html at this point:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1a1a2e">
<title>Metals Price Tracker</title>
<link rel="stylesheet" href="style.css">
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
</head>
<body>
<div class="app">
<header class="header">
<h1 class="header__title">Metals Tracker</h1>
<div class="header__status">
<span class="header__updated" id="lastUpdated">Loading...</span>
<button class="header__refresh-btn" id="refreshBtn" aria-label="Refresh prices">↻</button>
</div>
</header>
<div class="error-message" id="errorMessage" hidden>
<p>Unable to fetch prices. Will retry automatically.</p>
</div>
<main class="grid" id="priceGrid">
<div class="card card--gold" data-metal="gold">
<div class="card__header">
<span class="card__icon">Au</span>
<span class="card__name">Gold</span>
</div>
<div class="card__price" id="price-gold">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / oz t</span>
<span class="card__change" id="change-gold">-</span>
</div>
</div>
<div class="card card--silver" data-metal="silver">
<div class="card__header">
<span class="card__icon">Ag</span>
<span class="card__name">Silver</span>
</div>
<div class="card__price" id="price-silver">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / oz t</span>
<span class="card__change" id="change-silver">-</span>
</div>
</div>
<div class="card card--copper" data-metal="copper">
<div class="card__header">
<span class="card__icon">Cu</span>
<span class="card__name">Copper</span>
</div>
<div class="card__price" id="price-copper">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / lb</span>
<span class="card__change" id="change-copper">-</span>
</div>
</div>
<div class="card card--platinum" data-metal="platinum">
<div class="card__header">
<span class="card__icon">Pt</span>
<span class="card__name">Platinum</span>
</div>
<div class="card__price" id="price-platinum">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / oz t</span>
<span class="card__change" id="change-platinum">-</span>
</div>
</div>
<div class="card card--palladium" data-metal="palladium">
<div class="card__header">
<span class="card__icon">Pd</span>
<span class="card__name">Palladium</span>
</div>
<div class="card__price" id="price-palladium">
<span class="card__skeleton"></span>
</div>
<div class="card__footer">
<span class="card__unit">USD / oz t</span>
<span class="card__change" id="change-palladium">-</span>
</div>
</div>
</main>
<footer class="footer">
<p class="footer__countdown" id="countdown">Next refresh in 60s</p>
<p class="footer__attribution">Data: <a href="https://metalmetric.com" target="_blank" rel="noopener">MetalMetric</a> (COMEX/LBMA)</p>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>
Here is the complete style.css at this point:
:root {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--bg-card-hover: #1c2a4a;
--text-primary: #eaeaea;
--text-secondary: #a0a0b0;
--border-color: #2a2a4a;
--accent-gold: #ffd700;
--accent-silver: #c0c0c0;
--accent-copper: #b87333;
--accent-platinum: #7b8fa2;
--accent-palladium: #5ba8a0;
--change-up: #4caf50;
--change-down: #f44336;
--radius: 16px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
}
.app {
max-width: 900px;
margin: 0 auto;
padding: 20px 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 12px;
}
.header__title {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.header__status {
display: flex;
align-items: center;
gap: 12px;
}
.header__updated {
font-size: 0.85rem;
color: var(--text-secondary);
}
.header__refresh-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-primary);
font-size: 1.3rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.2s;
}
.header__refresh-btn:active {
transform: scale(0.92);
background: var(--bg-card-hover);
}
.header__refresh-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.error-message {
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 20px;
color: #f44336;
font-size: 0.9rem;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 500px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
.card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px;
border: 1px solid var(--border-color);
box-shadow: var(--shadow);
transition: transform 0.2s, border-color 0.3s;
min-height: 140px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card:active {
transform: scale(0.98);
}
.card--gold { border-top: 3px solid var(--accent-gold); }
.card--silver { border-top: 3px solid var(--accent-silver); }
.card--copper { border-top: 3px solid var(--accent-copper); }
.card--platinum { border-top: 3px solid var(--accent-platinum); }
.card--palladium { border-top: 3px solid var(--accent-palladium); }
.card__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.card__icon {
font-size: 1.1rem;
font-weight: 700;
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
}
.card--gold .card__icon { color: var(--accent-gold); }
.card--silver .card__icon { color: var(--accent-silver); }
.card--copper .card__icon { color: var(--accent-copper); }
.card--platinum .card__icon { color: var(--accent-platinum); }
.card--palladium .card__icon { color: var(--accent-palladium); }
.card__name {
font-size: 0.95rem;
color: var(--text-secondary);
font-weight: 500;
}
.card__price {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 8px;
min-height: 40px;
display: flex;
align-items: center;
}
.card__footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.card__unit {
font-size: 0.75rem;
color: var(--text-secondary);
}
.card__change {
font-size: 0.9rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
}
.card__change--up {
color: var(--change-up);
background: rgba(76, 175, 80, 0.1);
}
.card__change--down {
color: var(--change-down);
background: rgba(244, 67, 54, 0.1);
}
.card__skeleton {
display: block;
width: 120px;
height: 28px;
background: linear-gradient(90deg, var(--border-color) 25%, var(--bg-card-hover) 50%, var(--border-color) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 6px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes flash {
0% { background-color: var(--bg-card); }
50% { background-color: var(--bg-card-hover); }
100% { background-color: var(--bg-card); }
}
.card--updated {
animation: flash 0.6s ease;
}
.footer {
margin-top: 32px;
text-align: center;
color: var(--text-secondary);
font-size: 0.8rem;
}
.footer__countdown {
margin-bottom: 4px;
}
.footer a {
color: var(--text-secondary);
text-decoration: underline;
}
Your dashboard layout is complete. The cards are styled, responsive, and show smooth loading placeholders. Next up, you'll connect to the live MetalMetric API and fill those cards with real metal prices.
Connect to the Live Metals API
Your dashboard layout is built and styled. The cards look great, but they're stuck showing shimmer skeletons instead of real prices.
In this step, you'll connect your app to a free metals price API. You'll write JavaScript that fetches live spot prices and populates each card with real data from COMEX and LBMA markets.
In this step, get ready to:
- Write a function that fetches live metal prices from the MetalMetric API.
- Create a function that updates the dashboard cards with formatted price data.
- Verify live prices display correctly and test error handling.
Write the fetchPrices() function
The Fetch API is built into every modern browser. It lets JavaScript make HTTP requests to external servers and receive data back, usually in JSON format.
- In VS Code, open app.js (currently empty) and add the API configuration, state variables, and fetch function by pasting this code:
const API_URL = "https://metalmetric.com/api/gpt?action=spot_prices&metal=all";
const REFRESH_INTERVAL = 60;
// List of metals to track
const metals = ["gold", "silver", "copper", "platinum", "palladium"];
let previousPrices = {};
let countdownSeconds = REFRESH_INTERVAL;
let countdownTimer = null;
let refreshTimer = null;
// Grab references to DOM elements we'll update
const lastUpdatedEl = document.getElementById("lastUpdated");
const errorMessageEl = document.getElementById("errorMessage");
const refreshBtn = document.getElementById("refreshBtn");
const countdownEl = document.getElementById("countdown");
async function fetchPrices() {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error("API returned " + response.status);
}
const data = await response.json();
updateUI(data);
errorMessageEl.hidden = true;
} catch (error) {
console.error("Fetch failed:", error);
errorMessageEl.hidden = false;
}
}
What does this code do?
API_URL points to MetalMetric's free endpoint that returns spot prices for all five metals in one call. No API key or signup is needed.
The fetchPrices() function uses await fetch() to call the API, checks for errors with response.ok, and parses the JSON. If anything fails, the catch block logs the error and shows the error message div.
You won't see a change in the browser yet. The next chunk adds the function that actually puts prices on screen.
Create the updateUI() function
When the API responds, the raw JSON needs to be transformed into something visible. The updateUI() function loops through each metal, finds its card in the DOM, and writes the formatted price into it.
- In app.js, add the updateUI() function below fetchPrices() by pasting this code:
function updateUI(data) {
const prices = data.prices || data;
const timestamp = data.timestamp || new Date().toLocaleTimeString();
metals.forEach(function (metal) {
const priceEl = document.getElementById("price-" + metal);
const changeEl = document.getElementById("change-" + metal);
const cardEl = priceEl.closest(".card");
// Extract price whether it's a flat value or nested object
let price = null;
if (prices[metal] !== undefined) {
price = typeof prices[metal] === "object" ? prices[metal].price : prices[metal];
}
if (price === null || price === undefined) {
priceEl.textContent = "N/A";
return;
}
// Format price with 2 decimal places and dollar sign
const numericPrice = parseFloat(price);
priceEl.textContent = "$" + numericPrice.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
// Show price direction indicator compared to previous fetch
if (previousPrices[metal] !== undefined) {
const prev = previousPrices[metal];
if (numericPrice > prev) {
changeEl.textContent = "\u25B2";
changeEl.className = "card__change card__change--up";
} else if (numericPrice < prev) {
changeEl.textContent = "\u25BC";
changeEl.className = "card__change card__change--down";
} else {
changeEl.textContent = "-";
changeEl.className = "card__change";
}
} else {
changeEl.textContent = "-";
changeEl.className = "card__change";
}
// Flash the card if price changed since last fetch
if (previousPrices[metal] !== undefined && previousPrices[metal] !== numericPrice) {
cardEl.classList.remove("card--updated");
void cardEl.offsetWidth;
cardEl.classList.add("card--updated");
}
previousPrices[metal] = numericPrice;
});
lastUpdatedEl.textContent = "Updated: " + new Date().toLocaleTimeString();
resetCountdown();
}
What does this code do?
The function loops through each metal name and finds the matching card using the id attributes you set in the HTML (like price-gold and change-gold).
It handles two possible response shapes: the price might be a flat number or a nested object with a .price property. This makes the code resilient to API format changes.
The change indicator compares the current price to previousPrices. On the first load there's no previous data, so every card shows a dash. After subsequent refreshes, you'll see up/down arrows.
You won't see a change in the browser yet. The next chunk calls fetchPrices() to kick everything off.
Call fetchPrices() on page load
The functions are ready but nothing is calling them yet. A single line at the bottom of the file triggers the first fetch as soon as the page loads.
- At the very bottom of app.js (after the closing } of updateUI), add this line:
fetchPrices();
- Save app.js.
- Switch to your browser where Live Server is running and refresh the page.
- You should see dollar-formatted prices appear on each card (for example, Gold might show something like $2,650.30).
Why is the error message showing too?
You'll notice the red "Unable to fetch prices" message also appears below the header, even though the prices loaded. This is expected right now.
The updateUI() function calls resetCountdown() at the end, which doesn't exist yet. That triggers an error that the catch block picks up. You'll add resetCountdown() in the next step and the error message will disappear.
- Now test error handling. In app.js, temporarily change the API_URL value to something invalid, like https://metalmetric.com/api/gpt?action=BROKEN.
- Save and refresh. Confirm the cards stay in their skeleton loading state and the error message remains visible.
- Change API_URL back to the correct value:
const API_URL = "https://metalmetric.com/api/gpt?action=spot_prices&metal=all";
- Save app.js and refresh one more time to confirm prices are back.
Not seeing any prices?
- Open the browser console (press F12, then click the Console tab). Look for red error messages. A typo in the URL or a network issue will show here.
- Make sure your API_URL string matches the URL exactly, including the query parameters ?action=spot_prices&metal=all.
- Check that app.js is saved. Unsaved changes won't appear in the browser.
Why aren't my metal prices loading in the browser?
✔️ Awesome, I've got everything!
Great. Double check you've saved app.js before moving on.
ⓧ I'd like to double check the full code
Here's the complete app.js file at this point:
const API_URL = "https://metalmetric.com/api/gpt?action=spot_prices&metal=all";
const REFRESH_INTERVAL = 60;
const metals = ["gold", "silver", "copper", "platinum", "palladium"];
let previousPrices = {};
let countdownSeconds = REFRESH_INTERVAL;
let countdownTimer = null;
let refreshTimer = null;
const lastUpdatedEl = document.getElementById("lastUpdated");
const errorMessageEl = document.getElementById("errorMessage");
const refreshBtn = document.getElementById("refreshBtn");
const countdownEl = document.getElementById("countdown");
async function fetchPrices() {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error("API returned " + response.status);
}
const data = await response.json();
updateUI(data);
errorMessageEl.hidden = true;
} catch (error) {
console.error("Fetch failed:", error);
errorMessageEl.hidden = false;
}
}
function updateUI(data) {
const prices = data.prices || data;
const timestamp = data.timestamp || new Date().toLocaleTimeString();
metals.forEach(function (metal) {
const priceEl = document.getElementById("price-" + metal);
const changeEl = document.getElementById("change-" + metal);
const cardEl = priceEl.closest(".card");
let price = null;
if (prices[metal] !== undefined) {
price = typeof prices[metal] === "object" ? prices[metal].price : prices[metal];
}
if (price === null || price === undefined) {
priceEl.textContent = "N/A";
return;
}
const numericPrice = parseFloat(price);
priceEl.textContent = "$" + numericPrice.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
if (previousPrices[metal] !== undefined) {
const prev = previousPrices[metal];
if (numericPrice > prev) {
changeEl.textContent = "\u25B2";
changeEl.className = "card__change card__change--up";
} else if (numericPrice < prev) {
changeEl.textContent = "\u25BC";
changeEl.className = "card__change card__change--down";
} else {
changeEl.textContent = "-";
changeEl.className = "card__change";
}
} else {
changeEl.textContent = "-";
changeEl.className = "card__change";
}
if (previousPrices[metal] !== undefined && previousPrices[metal] !== numericPrice) {
cardEl.classList.remove("card--updated");
void cardEl.offsetWidth;
cardEl.classList.add("card--updated");
}
previousPrices[metal] = numericPrice;
});
lastUpdatedEl.textContent = "Updated: " + new Date().toLocaleTimeString();
resetCountdown();
}
fetchPrices();
Why no API key?
MetalMetric offers a free, open-CORS API for personal and non-commercial use. There's no signup, no authentication, and no secret key to manage. The only limit is 30 requests per minute per IP, which is plenty for a dashboard that refreshes every 60 seconds.
Your dashboard is pulling live data from real commodity markets. Next up, you'll add auto-refresh and price change indicators so the dashboard updates itself every 60 seconds.
Add Auto-Refresh and Price Change Indicators
Your dashboard fetches prices once on page load, but a real price tracker needs to stay alive. Precious metals move every minute, and users want to see changes the moment they happen.
In this step, you'll add a live countdown timer, automatic 60-second refreshes, and a manual refresh button for impatient users. You'll also register a service worker to prepare for PWA installability in the next step.
In this step, get ready to:
- Add a countdown timer that resets after each data refresh.
- Set up automatic price fetching every 60 seconds.
- Wire up the manual refresh button and register the service worker.
Add the countdown timer function
The footer of your page shows "Next refresh in 60s", but the number never changes. Your updateUI() function already calls resetCountdown() at the end, but that function does not exist yet. Time to define it.
- In app.js, scroll down to the very bottom of the updateUI() function's closing brace.
- Add the following resetCountdown() function below it:
function resetCountdown() {
countdownSeconds = REFRESH_INTERVAL;
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = setInterval(function () {
countdownSeconds--;
if (countdownSeconds <= 0) {
countdownSeconds = REFRESH_INTERVAL;
}
countdownEl.textContent = "Next refresh in " + countdownSeconds + "s";
}, 1000);
}
What does this code do?
- countdownSeconds = REFRESH_INTERVAL resets the countdown back to 60 every time fresh data arrives.
- clearInterval(countdownTimer) stops any previous countdown so timers don't stack up.
- The new setInterval ticks every 1000 ms (1 second), decrementing the counter and updating the footer text so users see a live "Next refresh in Xs" message.
- Save app.js.
- Refresh your browser. You should see the footer text count down from 60, dropping by one every second.
Footer stuck on "Next refresh in 60s"?
- Make sure resetCountdown() is placed after the closing brace of updateUI(), not inside it.
- Check that the countdownEl variable at the top of your file references document.getElementById("countdown"). A typo in the ID means the text never updates.
Why isn't my countdown timer updating the footer text?
Set up automatic data refreshing
The MetalMetric API updates prices every 60 seconds. Your dashboard should match that cadence by fetching fresh data automatically without requiring a page reload.
- Add the startAutoRefresh() function directly below resetCountdown():
function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(fetchPrices, REFRESH_INTERVAL * 1000);
}
What does this code do?
setInterval(fetchPrices, REFRESH_INTERVAL * 1000) calls your existing fetchPrices() function every 60,000 milliseconds (60 seconds). The clearInterval at the top prevents duplicate timers if the function is ever called twice.
Now you need to actually call this function when the page loads. The bottom of your file currently has a single fetchPrices(); call.
- Scroll to the very last line of app.js.
- Find this line:
fetchPrices();
- Add startAutoRefresh(); on the line directly below it, so it looks like this:
fetchPrices();
startAutoRefresh();
- Save app.js and refresh your browser.
- Watch the countdown reach 0. The prices should refresh and the countdown should reset back to 60.
Speed up testing
Waiting a full 60 seconds to confirm auto-refresh works is tedious. Temporarily change REFRESH_INTERVAL to 10 at the top of your file, test the refresh cycle, then change it back to 60 when you're satisfied.
Prices not refreshing automatically?
- Confirm startAutoRefresh(); appears on its own line below fetchPrices(); at the very bottom of the file.
- Open DevTools (F12) and check the Console tab for errors. A typo in the function name prevents it from running.
Why aren't my prices auto-refreshing every 60 seconds?
Wire up the manual refresh button
Touch users on mobile want to refresh immediately without waiting for the timer. The refresh button in the header already exists in your HTML, but tapping it does nothing yet.
- In app.js, add the handleManualRefresh() function and its event listener below startAutoRefresh():
function handleManualRefresh() {
// Disable button to prevent rapid tapping
refreshBtn.disabled = true;
fetchPrices();
// Reset the auto-refresh timer so the next tick is a full 60s away
if (refreshTimer) clearInterval(refreshTimer);
startAutoRefresh();
// Re-enable button after 3 seconds
setTimeout(function () {
refreshBtn.disabled = false;
}, 3000);
}
refreshBtn.addEventListener("click", handleManualRefresh);
What does this code do?
- refreshBtn.disabled = true grays out the button the instant it's tapped, preventing API spam from rapid clicks.
- After fetching, the auto-refresh timer is cleared and restarted so the next automatic refresh is a full 60 seconds away.
- setTimeout(..., 3000) re-enables the button after 3 seconds. This matches the MetalMetric rate limit of 30 requests per minute.
- The addEventListener line connects the button's click event to your handler function.
Finally, you need to register the service worker. This prepares the app for PWA installability, which you'll complete in the next step.
- Add the service worker registration block below the addEventListener line and above the final fetchPrices(); call:
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("sw.js").then(function (registration) {
console.log("Service Worker registered:", registration.scope);
}).catch(function (error) {
console.log("Service Worker registration failed:", error);
});
});
}
What does this code do?
- "serviceWorker" in navigator checks whether the browser supports service workers before attempting to register one.
- navigator.serviceWorker.register("sw.js") tells the browser to install the service worker from your sw.js file. That file is still empty, so the worker won't do anything yet.
- The registration happens inside window.addEventListener("load", ...) so it doesn't block the initial page render.
- Save app.js and refresh your browser.
- Click the circular refresh button in the header. It should gray out for 3 seconds, prices should reload, and the countdown should reset to 60.
Refresh button not responding?
- Make sure refreshBtn.addEventListener("click", handleManualRefresh); appears on its own line after the function definition, not inside it.
- Check the Console (F12) for errors. If you see "handleManualRefresh is not defined", the event listener line is placed above the function instead of below it.
Why isn't my refresh button working when I click it?
What about the price change arrows?
The up/down arrow indicators and the flash animation on price changes are already built into your updateUI() function from the previous step. Now that auto-refresh is active, you'll see them appear whenever a metal's price moves between fetches.
✔️ Awesome, I've got everything!
Great. Double check you've saved app.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete app.js file as it should look right now:
const API_URL = "https://metalmetric.com/api/gpt?action=spot_prices&metal=all";
const REFRESH_INTERVAL = 60;
const metals = ["gold", "silver", "copper", "platinum", "palladium"];
let previousPrices = {};
let countdownSeconds = REFRESH_INTERVAL;
let countdownTimer = null;
let refreshTimer = null;
const lastUpdatedEl = document.getElementById("lastUpdated");
const errorMessageEl = document.getElementById("errorMessage");
const refreshBtn = document.getElementById("refreshBtn");
const countdownEl = document.getElementById("countdown");
async function fetchPrices() {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error("API returned " + response.status);
}
const data = await response.json();
updateUI(data);
errorMessageEl.hidden = true;
} catch (error) {
console.error("Fetch failed:", error);
errorMessageEl.hidden = false;
}
}
function updateUI(data) {
const prices = data.prices || data;
const timestamp = data.timestamp || new Date().toLocaleTimeString();
metals.forEach(function (metal) {
const priceEl = document.getElementById("price-" + metal);
const changeEl = document.getElementById("change-" + metal);
const cardEl = priceEl.closest(".card");
let price = null;
if (prices[metal] !== undefined) {
price = typeof prices[metal] === "object" ? prices[metal].price : prices[metal];
}
if (price === null || price === undefined) {
priceEl.textContent = "N/A";
return;
}
const numericPrice = parseFloat(price);
priceEl.textContent = "$" + numericPrice.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
if (previousPrices[metal] !== undefined) {
const prev = previousPrices[metal];
if (numericPrice > prev) {
changeEl.textContent = "\u25B2";
changeEl.className = "card__change card__change--up";
} else if (numericPrice < prev) {
changeEl.textContent = "\u25BC";
changeEl.className = "card__change card__change--down";
} else {
changeEl.textContent = "-";
changeEl.className = "card__change";
}
} else {
changeEl.textContent = "-";
changeEl.className = "card__change";
}
if (previousPrices[metal] !== undefined && previousPrices[metal] !== numericPrice) {
cardEl.classList.remove("card--updated");
void cardEl.offsetWidth;
cardEl.classList.add("card--updated");
}
previousPrices[metal] = numericPrice;
});
lastUpdatedEl.textContent = "Updated: " + new Date().toLocaleTimeString();
resetCountdown();
}
function resetCountdown() {
countdownSeconds = REFRESH_INTERVAL;
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = setInterval(function () {
countdownSeconds--;
if (countdownSeconds <= 0) {
countdownSeconds = REFRESH_INTERVAL;
}
countdownEl.textContent = "Next refresh in " + countdownSeconds + "s";
}, 1000);
}
function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(fetchPrices, REFRESH_INTERVAL * 1000);
}
function handleManualRefresh() {
refreshBtn.disabled = true;
fetchPrices();
if (refreshTimer) clearInterval(refreshTimer);
startAutoRefresh();
setTimeout(function () {
refreshBtn.disabled = false;
}, 3000);
}
refreshBtn.addEventListener("click", handleManualRefresh);
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("sw.js").then(function (registration) {
console.log("Service Worker registered:", registration.scope);
}).catch(function (error) {
console.log("Service Worker registration failed:", error);
});
});
}
fetchPrices();
startAutoRefresh();
Your dashboard is now a living feed. Prices refresh automatically, the countdown keeps users informed, and the manual refresh button gives instant control. Next up, you'll turn this web page into an installable Android app.
Make It a PWA
Your metals dashboard is live and auto-refreshing. The final piece is turning it into a Progressive Web App so it can be installed on an Android phone like a native app.
A PWA needs two things beyond regular web files: a manifest that describes the app to the browser, and a service worker that handles caching and offline behavior. You already have the service worker registration wired up in app.js. In this step, you will fill in the manifest and write the service worker logic.
In this step, get ready to:
- Fill in the PWA manifest with installability fields.
- Write the service worker to cache app files and serve them offline.
- Test the PWA in Chrome DevTools and install it on Android.
Fill in the PWA manifest
The manifest tells the browser how your app should appear when installed. Chrome requires specific fields before it considers a web app installable.
- In VS Code, open manifest.json and paste the following:
{
"name": "Metals Price Tracker",
"short_name": "Metals",
"start_url": "/index.html",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
What does each field do?
- name and short_name appear on the install dialog and home screen.
- start_url tells the browser which page to load when the user opens the installed app.
- display set to standalone removes the browser chrome so the app looks and feels native.
- background_color and theme_color control the splash screen and status bar color on Android.
- icons provides the 192px and 512px images Chrome requires for the home screen icon and splash screen.
- Save manifest.json.
You will not see a visual change in the browser yet. The manifest is consumed by DevTools and the install prompt, which you will verify in substep 3.
Seeing a JSON parse error in the console?
- Check for trailing commas after the last item in the icons array. JSON does not allow trailing commas.
- Ensure every string value uses double quotes, not single quotes.
Help me fix a JSON syntax error in my manifest.json file.
Write the service worker
A service worker is a script that runs in the background, separate from your page. It intercepts network requests so it can serve cached files when the user is offline.
- Open sw.js and paste the cache name and assets list:
var CACHE_NAME = "metals-tracker-v1";
var ASSETS_TO_CACHE = [
"/index.html",
"/style.css",
"/app.js",
"/manifest.json",
"/icons/icon-192.png",
"/icons/icon-512.png"
];
self.addEventListener("install", function (event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
return cache.addAll(ASSETS_TO_CACHE);
})
);
});
What does this code do?
- CACHE_NAME gives your cache a versioned label so you can bust it later by changing the name.
- ASSETS_TO_CACHE lists every file the app needs to run offline. When the service worker installs, it downloads and stores all of these.
- The install event fires once when the browser first registers this service worker. event.waitUntil() keeps the worker in the installing state until all files are cached.
- Below the install listener, add the activate event handler:
self.addEventListener("activate", function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.map(function (name) {
if (name !== CACHE_NAME) {
return caches.delete(name);
}
})
);
})
);
});
What does the activate handler do?
When a new version of your service worker takes over, the activate event fires. This handler loops through all existing caches and deletes any that do not match the current CACHE_NAME. This prevents stale files from lingering after you update the app.
- Below the activate listener, add the fetch event handler:
self.addEventListener("fetch", function (event) {
// Let API requests go straight to the network
if (event.request.url.includes("metalmetric.com")) {
event.respondWith(
fetch(event.request).catch(function () {
return new Response(JSON.stringify({ error: "offline" }), {
headers: { "Content-Type": "application/json" }
});
})
);
return;
}
// For app shell files, serve from cache first
event.respondWith(
caches.match(event.request).then(function (cachedResponse) {
return cachedResponse || fetch(event.request);
})
);
});
What does the fetch handler do?
- API requests (anything containing metalmetric.com) always go to the network so prices stay fresh. If the network fails, it returns a JSON error response instead of crashing.
- All other requests (HTML, CSS, JS, icons) are served from the cache first. If not cached, they fall through to the network. This is the cache-first strategy for your app shell.
- Save sw.js.
- Refresh your browser. Open the browser console with F12 and look for the message Service Worker registered: followed by a scope URL.
Why is registration already working?
In the previous step, you added a service worker registration block at the bottom of app.js using navigator.serviceWorker.register("sw.js"). Now that sw.js has actual code, the browser picks it up on the next page load and runs the install and activate lifecycle.
Not seeing the registration message?
- Make sure sw.js is in the root of your metals-tracker folder, not inside a subfolder.
- Check that app.js contains navigator.serviceWorker.register("sw.js") near the bottom of the file.
- Try a hard refresh with Ctrl+Shift+R (Windows) or Cmd+Shift+R (macOS) to bypass the browser cache.
Help me debug why my service worker is not registering.
✔️ Awesome, I've got everything!
Great. Double check that you have saved both manifest.json and sw.js.
ⓧ I'd like to double check the full code
Here is the complete manifest.json:
{
"name": "Metals Price Tracker",
"short_name": "Metals",
"start_url": "/index.html",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Here is the complete sw.js:
var CACHE_NAME = "metals-tracker-v1";
var ASSETS_TO_CACHE = [
"/index.html",
"/style.css",
"/app.js",
"/manifest.json",
"/icons/icon-192.png",
"/icons/icon-512.png"
];
self.addEventListener("install", function (event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
return cache.addAll(ASSETS_TO_CACHE);
})
);
});
self.addEventListener("activate", function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.map(function (name) {
if (name !== CACHE_NAME) {
return caches.delete(name);
}
})
);
})
);
});
self.addEventListener("fetch", function (event) {
if (event.request.url.includes("metalmetric.com")) {
event.respondWith(
fetch(event.request).catch(function () {
return new Response(JSON.stringify({ error: "offline" }), {
headers: { "Content-Type": "application/json" }
});
})
);
return;
}
event.respondWith(
caches.match(event.request).then(function (cachedResponse) {
return cachedResponse || fetch(event.request);
})
);
});
Test the PWA and install on Android
Chrome DevTools has a dedicated panel for verifying everything a PWA needs. You will use it to confirm the manifest loaded correctly and the service worker is active.
- In Chrome, open DevTools by pressing F12 (Windows) or Cmd+Option+I (macOS).
- Click the Application tab in the top toolbar.
- Select Manifest in the left sidebar under Application.
- Verify that name, short_name, start_url, display, and both icons appear without errors.
- Select Service Workers in the left sidebar.
- Confirm the status shows sw.js as activated and running.
You should also see an install icon (a small monitor with a down arrow) in Chrome's address bar. Clicking it installs the app as a standalone desktop window.
What is the Application panel?
The Application panel in Chrome DevTools lets you inspect everything related to your PWA: the manifest fields, registered service workers, cached files, and storage usage. It is the primary tool for debugging installability issues.
Installing on Android from your local network
Chrome requires HTTPS before it shows the automatic install prompt. Since Live Server uses HTTP, the automatic prompt will not fire on Android. You can still install manually using Chrome's menu.
- On your Android phone, open Chrome and navigate to your Live Server's local IP address (for example, http://192.168.x.x:5500). Both devices must be on the same WiFi network.
- Tap the three-dot menu in Chrome.
- Tap Add to Home Screen.
- Tap Add to confirm.
The app now appears on your home screen with your icon. Tapping it opens the metals dashboard in standalone mode without the browser address bar.
Don't see the install icon in desktop Chrome?
- Open the Application panel, select Manifest, and check for any warnings or errors listed below the fields.
- Make sure Live Server is running and manifest.json is accessible at http://127.0.0.1:5500/manifest.json in a new browser tab.
- Try checking the Update on reload box in the Service Workers panel to force a fresh registration.
Help me figure out why Chrome is not showing the PWA install icon.
Secret mission
Add the Gold/Silver Ratio Card
The Gold/Silver Ratio tells investors how many ounces of silver it takes to buy one ounce of gold. When the ratio is high, silver is historically cheap. When it's low, gold looks favorable. Can you add a featured card that fetches this ratio from a separate API endpoint and displays a zone-based buy signal?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. All data comes from the free MetalMetric API (no account needed), and Live Server stops when you close VS Code.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The metals-tracker project folder on your Desktop (HTML, CSS, JS, icons, manifest, service worker).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep using the dashboard from any device on your network.
- Leave VS Code open with Live Server active to use the dashboard anytime.
- Any device on your WiFi can access the tracker by navigating to your computer's local IP address on port 5500.
Pause - I'll come back to this later
Shut down the running server to free up the port and reduce background activity. Your files stay intact for next time.
- In VS Code, click the Go Live button in the bottom status bar to stop Live Server.
- Close VS Code.
- To resume later, reopen the metals-tracker folder in VS Code and start Live Server again.
Delete - I don't want to use this again
Remove all project files and start fresh. There are no cloud resources or accounts to delete.
- Stop Live Server if it is still running by clicking Go Live in the VS Code status bar.
- Close VS Code.
- Delete the metals-tracker folder from your Desktop. On Windows, open File Explorer, right-click the folder, and select Delete.
- Optionally, clear the cached service worker in Chrome. Open Chrome DevTools with F12, go to the Application tab, click Storage in the left sidebar, and click Clear site data.
That's a Wrap!
That's a Wrap!
Nice work. You just built a live precious metals price tracker from scratch using vanilla HTML, CSS, and JavaScript, then turned it into an installable app for Android.
You've learned how to:
- Built a dark-themed, responsive dashboard with CSS Grid and custom properties that adapts from mobile to desktop.
- Integrated a live REST API (MetalMetric) using the Fetch API with auto-refresh, error handling, and price direction indicators.
- Turned a web page into an installable Progressive Web App using a manifest and service worker, accessible from any device on your local network.
- Secret Mission: Added a Gold/Silver Ratio card with zone-based color coding and contextual market signal indicators.
Ready to quiz yourself?