Build a Telecom Site Asset Register
Build a protected web app to track telecom sites, assets, alarms, and load.
Introduction
30 Second Summary
Telecom teams rely on accurate site records when equipment fails or power demand changes. A missing cabinet reference can turn a quick check into a long search.
In this project, you will build a browser-based register for fictional telecom sites. The finished app turns protected relational data into a searchable operations dashboard.
What You'll Build
Your final demo is a Chrome dashboard where persistent fictional telecom records become a filtered view of equipment risk.
By the end of this project, you'll have:
- A working site register for adding fictional sites with linked equipment records.
- Persistent records that return after a Chrome refresh inside the same protected browser session.
- A searchable operations dashboard where filters recalculate visible asset counts, active alarms, and total electrical load.
- Secret Mission: A hybrid-power audit of HYB-001 Training Site that identifies the most urgent risk across its Generator, Battery, and Hybrid System records.
Are there any prerequisites?
You'll need a Windows computer with internet access plus permission to create a free Supabase account.
The guide covers every setup task with fictional telecom records throughout.
Before We Start
Before the hands-on work begins, take a moment to commit to what you are building. You will use fictional data so you can learn how accurate records support telecom operations without exposing real infrastructure details.
Set Up Your Windows Workspace
Your fictional register needs more than a browser to become a working application. Google Chrome can display the finished page. A local development workspace lets you create the files and run them safely.
In this step, you’ll install Visual Studio Code for editing. You’ll use Node.js with npm to install Vite and serve the starter page on your computer.
In this step, get ready to:
- Install Visual Studio Code plus Node.js v24.22.0 LTS with npm.
- Create the starter files inside the telecom-site-register folder.
- Run the Vite development server and view the starter page in Chrome.
Install the editor and runtime
Visual Studio Code keeps your files and terminal in one workspace. Node.js runs the development tools that build your browser application.
- Visit the official Visual Studio Code Windows setup page.
- Download the User Setup installer.
- Run the downloaded installer by following its prompts.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into Windows Search.
- Press Enter to start Visual Studio Code.
- Select View from the top menu.
- Select Terminal to open the integrated terminal.
The terminal provides a direct check of the Node.js version available to your development tools. The second check confirms that npm is available beside it.
- Check the installed Node.js and npm versions by running these commands:
node --version
npm --version
What do these checks prove?
- The first command prints the Node.js version available in this terminal.
- The second command prints the npm version installed with Node.js.
✔️ I see the required version
That’s the first piece in place. Node.js v24.22.0 and npm are ready to run this project’s development tools.
ⓧ I see an older version
The installed Node.js version is below the version used by this project. Updating it gives Vite the runtime it expects.
- Visit the official Node.js download page.
- Download the Windows installer for v24.22.0 LTS.
- Run the downloaded installer by following its prompts.
- Click the trash can icon in the VS Code terminal panel to close the old terminal.
- Select Terminal from the top menu.
- Select New Terminal to load the updated PATH.
- Check the updated versions by running these commands:
node --version
npm --version
What should I see?
The first command should now show v24.22.0. The second command should show an npm version number.
ⓧ Command not found
Windows cannot find Node.js in the current terminal. Installing the LTS release provides both Node.js and npm.
- Visit the official Node.js download page.
- Download the Windows installer for v24.22.0 LTS.
- Run the downloaded installer by following its prompts.
- Click the trash can icon in the VS Code terminal panel to close the old terminal.
- Select Terminal from the top menu.
- Select New Terminal to load the updated PATH.
- Verify the installation by running these commands:
node --version
npm --version
What should I see?
The first command should show v24.22.0. The second command should show an npm version number.
Still cannot run Node.js?
Make sure you created a new terminal after the installer finished. A terminal left open during installation still uses the previous PATH.
Still stuck? Help me check why Node.js is unavailable in my VS Code terminal.
Create the starter project
A Visual Studio Code workspace keeps the project files and terminal focused on the same folder. You’ll create the register on your Desktop so it stays easy to find.
- Select File from the Visual Studio Code menu.
- Select Open Folder....
- Select your Desktop in the folder picker.
- Select New Folder.
- Enter telecom-site-register as the folder name.
- Select Select Folder.
- Select Yes, I trust the authors if Workspace Trust appears.
Your telecom-site-register folder now appears in the Explorer sidebar. That folder becomes the home for every project file you create.
The package.json file is the project’s JSON manifest. It names the development commands and pins Vite to 8.3.4.
- Select the New File... control at the top of the Explorer sidebar.
- Enter package.json as the file name.
- Press Enter to create the file.
- Define the project scripts and Vite dependency by pasting this into package.json:
{
"name": "telecom-site-register",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "8.3.4"
}
}
What does this file do?
- The scripts object gives npm short commands for development, building, and previewing.
- The devDependencies object installs Vite 8.3.4 as a development tool.
- The private value prevents accidental publication as an npm package.
- Press Ctrl+S to save package.json.
- Switch back to the integrated terminal from earlier.
- Install the pinned Vite dependency by running this command:
npm install
What does this command do?
npm reads package.json and downloads Vite 8.3.4. It stores the installed package inside node_modules.
- Confirm that node_modules now appears in the Explorer sidebar.
Did the Vite installation fail?
Check that the terminal path ends with telecom-site-register. npm must run beside package.json.
Still stuck? Help me diagnose why npm install fails in this project.
The starter JavaScript module will hold the browser logic as the register grows. For now, it keeps a reference to the connection banner and provides a helper for updating that banner.
- Right-click the telecom-site-register folder in the Explorer sidebar.
- Select New Folder.
- Enter src as the folder name.
- Press Enter to create the folder.
- Right-click the new src folder.
- Select New File.
- Enter main.js as the file name.
- Create the starter connection helper by pasting this into src/main.js:
const connectionStatus = document.querySelector('#connection-status')
function setConnection(text, kind = '') {
connectionStatus.textContent = text
connectionStatus.className = `connection-status ${kind}`.trim()
}
What does this module do?
- The connectionStatus reference finds the status banner in the browser page.
- The setConnection() helper can update the banner text and visual state later in the project.
- Press Ctrl+S to save src/main.js.
- Confirm that main.js appears beneath src in the Explorer sidebar.
Is main.js in the wrong place?
Check that the Explorer shows main.js nested directly under src. Move the file into that folder if it appears beside package.json.
Still stuck? Help me place main.js in the correct project folder.
The index.html file gives the browser its first visible page. It also loads src/main.js as a module.
- Select the telecom-site-register folder in the Explorer sidebar.
- Select the New File... control.
- Enter index.html as the file name.
- Press Enter to create the file.
- Build the starter page by pasting this into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A fictional telecom site and equipment register for learning purposes."
/>
<title>Telecom Site Asset Register</title>
</head>
<body>
<header class="app-header">
<div>
<p class="eyebrow">Operations training project</p>
<h1>Telecom Site Asset Register</h1>
<p class="subtitle">
Collect fictional site, equipment, alarm, cabinet, and power records.
</p>
</div>
<div id="connection-status" class="connection-status" role="status">
Connecting...
</div>
</header>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this page contain?
- The metadata gives the browser a title and a description for this fictional training application.
- The header displays the project name plus its data-collection purpose.
- The module script loads src/main.js through Vite.
- Press Ctrl+S to save index.html.
- Confirm that the Explorer shows package.json and index.html beside the src folder.
Is the page file nested incorrectly?
Make sure index.html appears directly inside telecom-site-register. It should sit beside package.json.
Still stuck? Help me check the starter project file structure.
✔️ Awesome, I've got everything!
Great. Double check that all three starter files are saved inside telecom-site-register.
ⓧ I'd like to double check the full code
Compare each saved file with these complete starter versions.
{
"name": "telecom-site-register",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "8.3.4"
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A fictional telecom site and equipment register for learning purposes."
/>
<title>Telecom Site Asset Register</title>
</head>
<body>
<header class="app-header">
<div>
<p class="eyebrow">Operations training project</p>
<h1>Telecom Site Asset Register</h1>
<p class="subtitle">
Collect fictional site, equipment, alarm, cabinet, and power records.
</p>
</div>
<div id="connection-status" class="connection-status" role="status">
Connecting...
</div>
</header>
<script type="module" src="/src/main.js"></script>
</body>
</html>
const connectionStatus = document.querySelector('#connection-status')
function setConnection(text, kind = '') {
connectionStatus.textContent = text
connectionStatus.className = `connection-status ${kind}`.trim()
}
Run the starter page
Vite’s development server turns your saved project files into a local website. The process keeps the terminal occupied until you stop it with Ctrl+C.
Before you start the server, what heading do you expect Chrome to display from index.html?
- Switch back to the integrated terminal from earlier.
- Start the Vite development server by running this command:
npm run dev
What does this command do?
npm reads the dev script from package.json. Vite then serves the project at http://localhost:5173.
- Press the Windows key to open Windows Search.
- Type Google Chrome into Windows Search.
- Press Enter to start Chrome.
- Press Ctrl+L to focus the address bar.
- Enter http://localhost:5173 in the address bar.
- Press Enter to load the starter page.
You’ll see the heading Telecom Site Asset Register with the training-project description beneath it. Your local development server is now delivering the starter application to Chrome.
Cannot see the starter page?
Keep the terminal running while Chrome loads the local address. Stopping the terminal also stops the Vite server.
Check that the terminal path ends with telecom-site-register. Confirm that index.html is saved inside that folder.
Still stuck? Help me diagnose why the Vite starter page does not load.
Your Windows workspace is ready. Next, you’ll turn this starter page into a browser-based register that accepts fictional site and equipment records.
Build the In-Memory Register
Your starter page proves the local Vite development server can deliver your project to Chrome. A telecom register now needs forms that turn fictional field observations into structured records.
A browser-memory version gives you a working register before you add a database. The deliberate refresh at the end exposes why temporary JavaScript arrays cannot protect valuable records.
In this step, get ready to:
- Create forms for fictional telecom sites and related assets.
- Style a responsive operations dashboard for desktop and narrow screens.
- Store sample records in browser memory and test what happens after a refresh.
Build the data-entry interface
The interface separates site details from the equipment attached to each site. This mirrors the one-site-to-many-assets relationship you use when the database arrives.
- In the Explorer sidebar of Visual Studio Code, select index.html.
- Replace the starter markup in index.html by copying the complete interface below:
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="description" content="A fictional telecom site and equipment register for learning purposes." /><title>Telecom Site Asset Register</title><link rel="stylesheet" href="/src/style.css" /></head>
<body>
<!-- Show the project purpose and current connection state. -->
<header class="app-header"><div><p class="eyebrow">Operations training project</p><h1>Telecom Site Asset Register</h1><p class="subtitle">Collect fictional site, equipment, alarm, cabinet, and power records.</p></div><div id="connection-status" class="connection-status" role="status">Connecting...</div></header>
<main class="page-shell">
<section class="notice" aria-label="Data safety notice"><strong>Training data only.</strong> Do not enter real site coordinates, access details, alarms, serial numbers, or power capacity without company approval.</section>
<!-- Keep site details separate from the assets that belong to each site. -->
<section class="form-grid" aria-label="Data entry forms">
<article class="panel"><h2>Add a site</h2><form id="site-form" class="form-stack">
<label>Site code<input name="site_code" placeholder="TRN-001" required /></label><label>Site name<input name="site_name" placeholder="North Training Site" required /></label><label>Region<input name="region" placeholder="Training Region" required /></label>
<label>Site status<select name="site_status" required><option>Operational</option><option>Degraded</option><option>Offline</option><option>Maintenance</option></select></label><button type="submit">Save site</button>
</form></article>
<article class="panel"><h2>Add site equipment</h2><form id="asset-form" class="form-stack">
<label>Site<select id="asset-site" name="site_id" required disabled><option value="">Add a site first</option></select></label>
<div class="two-column"><label>Asset type<select name="asset_type" required><option>Equipment</option><option>Alarm</option><option>Generator</option><option>Cabinet</option><option>Battery</option><option>Hybrid System</option></select></label><label>Operational status<select name="operational_status" required><option>Operational</option><option>Degraded</option><option>Alarm</option><option>Offline</option><option>Maintenance</option></select></label></div>
<div class="two-column"><label>Manufacturer<input name="manufacturer" placeholder="Training Vendor" /></label><label>Model<input name="model" placeholder="Model-X" /></label></div><div class="two-column"><label>Serial or asset number<input name="serial_number" placeholder="SAMPLE-001" /></label><label>Cabinet reference<input name="cabinet_reference" placeholder="CAB-A" /></label></div>
<div class="two-column"><label>Alarm severity<select name="alarm_severity"><option>None</option><option>Warning</option><option>Major</option><option>Critical</option></select></label><label>Electrical load in kW<input name="rated_load_kw" type="number" min="0" step="0.01" value="0" required /></label></div>
<div class="two-column"><label>Capacity or rating<input name="capacity_value" type="number" min="0" step="0.01" /></label><label>Capacity unit<input name="capacity_unit" placeholder="kVA, Ah, kW" /></label></div><label>Power source<input name="power_source" placeholder="Grid, solar, generator, battery" /></label><label>Notes<textarea name="notes" rows="3" placeholder="Use fictional notes only"></textarea></label><button type="submit">Save equipment record</button>
</form></article>
</section>
<!-- Summarize only the records visible through the current filters. -->
<section class="dashboard" aria-labelledby="dashboard-title"><div class="dashboard-heading"><div><p class="eyebrow">Current session</p><h2 id="dashboard-title">Operations dashboard</h2></div><div class="filters"><label><span class="visually-hidden">Search records</span><input id="search-input" type="search" placeholder="Search site, model, cabinet..." /></label><label><span class="visually-hidden">Filter by asset type</span><select id="type-filter"><option value="All">All asset types</option><option>Equipment</option><option>Alarm</option><option>Generator</option><option>Cabinet</option><option>Battery</option><option>Hybrid System</option></select></label></div></div>
<div class="summary-grid" aria-label="Visible record summary"><article class="summary-card"><span>Sites</span><strong id="site-count">0</strong></article><article class="summary-card"><span>Assets</span><strong id="asset-count">0</strong></article><article class="summary-card alert-card"><span>Active alarms</span><strong id="alarm-count">0</strong></article><article class="summary-card"><span>Total load</span><strong><span id="load-total">0</span> kW</strong></article></div><p id="message" class="message" role="status"></p>
<div class="table-wrap"><table><thead><tr><th>Site</th><th>Asset</th><th>Identity</th><th>Cabinet</th><th>Status</th><th>Alarm</th><th>Capacity</th><th>Power</th><th>Load</th></tr></thead><tbody id="records-body"></tbody></table><p id="empty-state" class="empty-state">Add a site and equipment record to start the register.</p></div>
</section>
</main><script type="module" src="/src/main.js"></script>
</body>
</html>
How is the interface organized?
- The site-form captures the location-level code, name, region, and operating status.
- The asset-form captures equipment identity, condition, alarm severity, capacity, power source, and electrical load.
- The dashboard contains filters, summary cards, a records table, and an empty-state message.
- The training-data notice keeps real infrastructure details outside this learning project.
- Save index.html.
- Return to Chrome from earlier.
- Refresh http://localhost:5173.
You should see the site form, equipment form, summary cards, filters, table headings, and training-data notice as an unstyled page.
✔️ Awesome, I've got everything!
The full set of forms and dashboard elements is visible in Chrome. Your interface now has every field needed for the in-memory register.
ⓧ I'd like to double check the full code
- Compare your complete index.html file with this reference:
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="description" content="A fictional telecom site and equipment register for learning purposes." /><title>Telecom Site Asset Register</title><link rel="stylesheet" href="/src/style.css" /></head>
<body>
<!-- Show the project purpose and current connection state. -->
<header class="app-header"><div><p class="eyebrow">Operations training project</p><h1>Telecom Site Asset Register</h1><p class="subtitle">Collect fictional site, equipment, alarm, cabinet, and power records.</p></div><div id="connection-status" class="connection-status" role="status">Connecting...</div></header>
<main class="page-shell">
<section class="notice" aria-label="Data safety notice"><strong>Training data only.</strong> Do not enter real site coordinates, access details, alarms, serial numbers, or power capacity without company approval.</section>
<!-- Keep site details separate from the assets that belong to each site. -->
<section class="form-grid" aria-label="Data entry forms">
<article class="panel"><h2>Add a site</h2><form id="site-form" class="form-stack">
<label>Site code<input name="site_code" placeholder="TRN-001" required /></label><label>Site name<input name="site_name" placeholder="North Training Site" required /></label><label>Region<input name="region" placeholder="Training Region" required /></label>
<label>Site status<select name="site_status" required><option>Operational</option><option>Degraded</option><option>Offline</option><option>Maintenance</option></select></label><button type="submit">Save site</button>
</form></article>
<article class="panel"><h2>Add site equipment</h2><form id="asset-form" class="form-stack">
<label>Site<select id="asset-site" name="site_id" required disabled><option value="">Add a site first</option></select></label>
<div class="two-column"><label>Asset type<select name="asset_type" required><option>Equipment</option><option>Alarm</option><option>Generator</option><option>Cabinet</option><option>Battery</option><option>Hybrid System</option></select></label><label>Operational status<select name="operational_status" required><option>Operational</option><option>Degraded</option><option>Alarm</option><option>Offline</option><option>Maintenance</option></select></label></div>
<div class="two-column"><label>Manufacturer<input name="manufacturer" placeholder="Training Vendor" /></label><label>Model<input name="model" placeholder="Model-X" /></label></div><div class="two-column"><label>Serial or asset number<input name="serial_number" placeholder="SAMPLE-001" /></label><label>Cabinet reference<input name="cabinet_reference" placeholder="CAB-A" /></label></div>
<div class="two-column"><label>Alarm severity<select name="alarm_severity"><option>None</option><option>Warning</option><option>Major</option><option>Critical</option></select></label><label>Electrical load in kW<input name="rated_load_kw" type="number" min="0" step="0.01" value="0" required /></label></div>
<div class="two-column"><label>Capacity or rating<input name="capacity_value" type="number" min="0" step="0.01" /></label><label>Capacity unit<input name="capacity_unit" placeholder="kVA, Ah, kW" /></label></div><label>Power source<input name="power_source" placeholder="Grid, solar, generator, battery" /></label><label>Notes<textarea name="notes" rows="3" placeholder="Use fictional notes only"></textarea></label><button type="submit">Save equipment record</button>
</form></article>
</section>
<!-- Summarize only the records visible through the current filters. -->
<section class="dashboard" aria-labelledby="dashboard-title"><div class="dashboard-heading"><div><p class="eyebrow">Current session</p><h2 id="dashboard-title">Operations dashboard</h2></div><div class="filters"><label><span class="visually-hidden">Search records</span><input id="search-input" type="search" placeholder="Search site, model, cabinet..." /></label><label><span class="visually-hidden">Filter by asset type</span><select id="type-filter"><option value="All">All asset types</option><option>Equipment</option><option>Alarm</option><option>Generator</option><option>Cabinet</option><option>Battery</option><option>Hybrid System</option></select></label></div></div>
<div class="summary-grid" aria-label="Visible record summary"><article class="summary-card"><span>Sites</span><strong id="site-count">0</strong></article><article class="summary-card"><span>Assets</span><strong id="asset-count">0</strong></article><article class="summary-card alert-card"><span>Active alarms</span><strong id="alarm-count">0</strong></article><article class="summary-card"><span>Total load</span><strong><span id="load-total">0</span> kW</strong></article></div><p id="message" class="message" role="status"></p>
<div class="table-wrap"><table><thead><tr><th>Site</th><th>Asset</th><th>Identity</th><th>Cabinet</th><th>Status</th><th>Alarm</th><th>Capacity</th><th>Power</th><th>Load</th></tr></thead><tbody id="records-body"></tbody></table><p id="empty-state" class="empty-state">Add a site and equipment record to start the register.</p></div>
</section>
</main><script type="module" src="/src/main.js"></script>
</body>
</html>
Missing part of the interface?
- Confirm that index.html contains both forms before the dashboard section.
- Check that every opening form element has a matching closing element.
- Confirm that the script source still points to /src/main.js.
Still stuck? Help me compare my telecom register HTML with the required form and dashboard structure.
Style the responsive dashboard
The current HTML provides structure. CSS now separates data entry from operational output while keeping the register usable on a narrow browser window.
- Create src/style.css inside the existing src folder from the Explorer sidebar.
- Add the page, header, form, and panel styles by copying this first section into src/style.css:
/* Establish the shared color, type, and sizing rules. */
:root { color: #142033; background: #eef3f8; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select, textarea { font: inherit; }
/* Separate the project identity from its working area. */
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 32px clamp(20px, 5vw, 72px); color: white; background: linear-gradient(120deg, #0b335f, #086c79); }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 8px; font-size: clamp(2rem, 5vw, 3.4rem); }
h2 { margin-bottom: 20px; }
.eyebrow { margin-bottom: 6px; color: #7bd6d0; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.subtitle { margin-bottom: 0; color: #d6eaf2; }
.connection-status { flex: 0 0 auto; padding: 10px 14px; border: 1px solid rgb(255 255 255 / 35%); border-radius: 999px; background: rgb(255 255 255 / 12%); font-size: 0.9rem; font-weight: 700; }
.connection-status.success { color: #072d2a; border-color: #8ce0c1; background: #aef0d7; }
.connection-status.error { color: #521313; border-color: #ffb4b4; background: #ffd1d1; }
.page-shell { width: min(1400px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 64px; }
.notice { margin-bottom: 24px; padding: 16px 18px; border-left: 5px solid #d88c00; border-radius: 10px; background: #fff5d8; }
.form-grid { display: grid; grid-template-columns: minmax(260px, 0.75fr) minmax(320px, 1.25fr); gap: 24px; }
.panel, .dashboard { border: 1px solid #d8e1ea; border-radius: 16px; background: white; box-shadow: 0 12px 32px rgb(23 49 77 / 8%); }
.panel { padding: 24px; }
.form-stack { display: grid; gap: 14px; }
.two-column { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
label { display: grid; gap: 6px; color: #31445b; font-size: 0.88rem; font-weight: 700; }
input, select, textarea { width: 100%; padding: 11px 12px; color: #142033; border: 1px solid #bac8d6; border-radius: 9px; background: white; }
input:focus, select:focus, textarea:focus { border-color: #087c83; outline: 3px solid rgb(8 124 131 / 16%); }
button { padding: 12px 16px; color: white; border: 0; border-radius: 9px; background: #086c79; font-weight: 800; cursor: pointer; }
button:hover { background: #075866; }
button:disabled, select:disabled { cursor: not-allowed; opacity: 0.6; }
What do these styles establish?
- The header uses a dark gradient to separate the register identity from the working area.
- The two-column form grid gives the equipment form more space than the shorter site form.
- Shared input styles keep text fields, selectors, and notes visually consistent.
- Connection-state classes prepare the banner for successful and failed states.
- Save src/style.css.
- Refresh Chrome.
You should see a blue-green header, a yellow training notice, and two white form panels arranged beside each other.
Still seeing plain forms?
- Confirm that src/style.css sits inside the existing src folder.
- Check that the stylesheet link in index.html uses /src/style.css.
Still stuck? Help me find why my Vite page is not applying the styles from src/style.css.
- Add the dashboard, table, alert, empty-state, and responsive styles below the existing CSS:
/* Turn the lower section into an operational dashboard. */
.dashboard { margin-top: 24px; padding: 24px; }
.dashboard-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
.dashboard-heading h2 { margin-bottom: 0; }
.dashboard .eyebrow { color: #087c83; }
.filters { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(170px, 0.7fr); gap: 10px; width: min(560px, 100%); }
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(130px, 1fr)); gap: 14px; margin: 24px 0; }
.summary-card { padding: 16px; border: 1px solid #d8e1ea; border-radius: 12px; background: #f7fafc; }
.summary-card span { display: block; margin-bottom: 6px; color: #63758a; font-size: 0.82rem; font-weight: 700; text-transform: uppercase; }
.summary-card strong { font-size: 1.65rem; }
.alert-card { border-color: #edc5c5; background: #fff4f4; }
.message { min-height: 24px; margin-bottom: 10px; color: #086c79; font-weight: 700; }
.message.error { color: #a11c1c; }
.table-wrap { overflow-x: auto; }
table { width: 100%; min-width: 1050px; border-collapse: collapse; }
th, td { padding: 12px 10px; border-bottom: 1px solid #e0e7ef; text-align: left; vertical-align: top; }
th { color: #53667b; background: #f4f7fa; font-size: 0.77rem; letter-spacing: 0.04em; text-transform: uppercase; }
td { font-size: 0.9rem; }
.muted { color: #728397; }
.status-pill { display: inline-block; padding: 4px 8px; border-radius: 999px; background: #e8eef4; font-size: 0.78rem; font-weight: 800; }
.status-pill.alert { color: #7a1717; background: #ffdede; }
.empty-state { margin: 0; padding: 28px; color: #66788c; text-align: center; }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Stack controls when the browser becomes narrow. */
@media (max-width: 900px) { .app-header, .dashboard-heading { align-items: stretch; flex-direction: column; } .form-grid { grid-template-columns: 1fr; } .summary-grid { grid-template-columns: 1fr 1fr; } .filters { width: 100%; } }
@media (max-width: 560px) { .two-column, .filters, .summary-grid { grid-template-columns: 1fr; } .page-shell { width: min(100% - 20px, 1400px); } }
How does the dashboard adapt?
- The summary grid places four operational totals on one row when enough width is available.
- The table keeps its columns readable inside a horizontal scrolling area.
- The first media query stacks the forms while preserving two summary columns.
- The narrowest layout places form fields, filters, and summary cards in one column.
- Save src/style.css.
- Refresh Chrome.
- Narrow the Chrome window until the forms stack vertically.
You should see the summary cards reduce to two columns before reaching one column on the narrowest layout. The forms should remain readable without overlapping.
✔️ Awesome, I've got everything!
The register now has a desktop layout and a narrow-screen layout. The dashboard remains usable as the browser width changes.
ⓧ I'd like to double check the full code
- Compare your complete src/style.css file with this reference:
/* Establish the shared color, type, and sizing rules. */
:root { color: #142033; background: #eef3f8; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select, textarea { font: inherit; }
/* Separate the project identity from its working area. */
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 32px clamp(20px, 5vw, 72px); color: white; background: linear-gradient(120deg, #0b335f, #086c79); }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 8px; font-size: clamp(2rem, 5vw, 3.4rem); }
h2 { margin-bottom: 20px; }
.eyebrow { margin-bottom: 6px; color: #7bd6d0; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.subtitle { margin-bottom: 0; color: #d6eaf2; }
.connection-status { flex: 0 0 auto; padding: 10px 14px; border: 1px solid rgb(255 255 255 / 35%); border-radius: 999px; background: rgb(255 255 255 / 12%); font-size: 0.9rem; font-weight: 700; }
.connection-status.success { color: #072d2a; border-color: #8ce0c1; background: #aef0d7; }
.connection-status.error { color: #521313; border-color: #ffb4b4; background: #ffd1d1; }
.page-shell { width: min(1400px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 64px; }
.notice { margin-bottom: 24px; padding: 16px 18px; border-left: 5px solid #d88c00; border-radius: 10px; background: #fff5d8; }
.form-grid { display: grid; grid-template-columns: minmax(260px, 0.75fr) minmax(320px, 1.25fr); gap: 24px; }
.panel, .dashboard { border: 1px solid #d8e1ea; border-radius: 16px; background: white; box-shadow: 0 12px 32px rgb(23 49 77 / 8%); }
.panel { padding: 24px; }
.form-stack { display: grid; gap: 14px; }
.two-column { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
label { display: grid; gap: 6px; color: #31445b; font-size: 0.88rem; font-weight: 700; }
input, select, textarea { width: 100%; padding: 11px 12px; color: #142033; border: 1px solid #bac8d6; border-radius: 9px; background: white; }
input:focus, select:focus, textarea:focus { border-color: #087c83; outline: 3px solid rgb(8 124 131 / 16%); }
button { padding: 12px 16px; color: white; border: 0; border-radius: 9px; background: #086c79; font-weight: 800; cursor: pointer; }
button:hover { background: #075866; }
button:disabled, select:disabled { cursor: not-allowed; opacity: 0.6; }
/* Turn the lower section into an operational dashboard. */
.dashboard { margin-top: 24px; padding: 24px; }
.dashboard-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
.dashboard-heading h2 { margin-bottom: 0; }
.dashboard .eyebrow { color: #087c83; }
.filters { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(170px, 0.7fr); gap: 10px; width: min(560px, 100%); }
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(130px, 1fr)); gap: 14px; margin: 24px 0; }
.summary-card { padding: 16px; border: 1px solid #d8e1ea; border-radius: 12px; background: #f7fafc; }
.summary-card span { display: block; margin-bottom: 6px; color: #63758a; font-size: 0.82rem; font-weight: 700; text-transform: uppercase; }
.summary-card strong { font-size: 1.65rem; }
.alert-card { border-color: #edc5c5; background: #fff4f4; }
.message { min-height: 24px; margin-bottom: 10px; color: #086c79; font-weight: 700; }
.message.error { color: #a11c1c; }
.table-wrap { overflow-x: auto; }
table { width: 100%; min-width: 1050px; border-collapse: collapse; }
th, td { padding: 12px 10px; border-bottom: 1px solid #e0e7ef; text-align: left; vertical-align: top; }
th { color: #53667b; background: #f4f7fa; font-size: 0.77rem; letter-spacing: 0.04em; text-transform: uppercase; }
td { font-size: 0.9rem; }
.muted { color: #728397; }
.status-pill { display: inline-block; padding: 4px 8px; border-radius: 999px; background: #e8eef4; font-size: 0.78rem; font-weight: 800; }
.status-pill.alert { color: #7a1717; background: #ffdede; }
.empty-state { margin: 0; padding: 28px; color: #66788c; text-align: center; }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Stack controls when the browser becomes narrow. */
@media (max-width: 900px) { .app-header, .dashboard-heading { align-items: stretch; flex-direction: column; } .form-grid { grid-template-columns: 1fr; } .summary-grid { grid-template-columns: 1fr 1fr; } .filters { width: 100%; } }
@media (max-width: 560px) { .two-column, .filters, .summary-grid { grid-template-columns: 1fr; } .page-shell { width: min(100% - 20px, 1400px); } }
Does the narrow layout overlap?
- Confirm that both media queries sit after the regular dashboard styles.
- Check that the first breakpoint uses 900px.
- Check that the narrowest breakpoint uses 560px.
Still stuck? Help me debug the responsive CSS for my telecom register forms and summary cards.
Add temporary browser-memory logic
The page can collect information visually, but it cannot respond to submissions yet. Temporary arrays now hold fictional sites and assets for the lifetime of the current browser page.
- Select src/main.js in the Explorer sidebar.
- Replace the starter module with the interface references, temporary state, and safe text helper below:
// Keep references to the interface elements updated by the register.
const connectionStatus = document.querySelector('#connection-status')
const message = document.querySelector('#message')
const siteForm = document.querySelector('#site-form')
const assetForm = document.querySelector('#asset-form')
const assetSite = document.querySelector('#asset-site')
const searchInput = document.querySelector('#search-input')
const typeFilter = document.querySelector('#type-filter')
const recordsBody = document.querySelector('#records-body')
const emptyState = document.querySelector('#empty-state')
const siteCount = document.querySelector('#site-count')
const assetCount = document.querySelector('#asset-count')
const alarmCount = document.querySelector('#alarm-count')
const loadTotal = document.querySelector('#load-total')
// Store records only for the lifetime of this browser page.
const state = {
sites: [],
assets: [],
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
.replaceAll('"', '"').replaceAll("'", ''')
}
connectionStatus.textContent = 'In-memory demo active'
connectionStatus.className = 'connection-status success'
What does this setup hold?
- The element references connect JavaScript to the forms, filters, cards, table, and status messages.
- The state object contains separate sites and assets arrays.
- The escapeHtml() helper prevents form text from being interpreted as table markup.
- The green connection banner confirms that the local in-memory module is running.
- Save src/main.js.
- Return to Chrome.
You should see the connection banner change from Connecting... to In-memory demo active.
Is the banner still connecting?
- Confirm that src/main.js is saved.
- Check that the HTML contains the connection-status element ID.
- Look for a missing quote or parenthesis near the element references.
Still stuck? Help me debug why the in-memory connection banner is not updating.
- Add the site submission handler and site-selector renderer below the existing JavaScript:
function saveSite(event) {
event.preventDefault()
const formData = new FormData(siteForm)
const site = {
id: state.sites.length + 1,
site_code: formData.get('site_code').trim(),
site_name: formData.get('site_name').trim(),
region: formData.get('region').trim(),
site_status: formData.get('site_status'),
}
state.sites.push(site)
siteForm.reset()
renderSiteOptions()
renderDashboard()
message.textContent = `Saved site ${site.site_code} in browser memory.`
}
function renderSiteOptions() {
if (state.sites.length === 0) {
assetSite.innerHTML = '<option value="">Add a site first</option>'
assetSite.disabled = true
return
}
assetSite.innerHTML = state.sites.map((site) => `<option value="${site.id}">${escapeHtml(site.site_code)} - ${escapeHtml(site.site_name)}</option>`).join('')
assetSite.disabled = false
}
siteForm.addEventListener('submit', saveSite)
renderSiteOptions()
message.textContent = 'Site form ready for a fictional record.'
How are sites stored?
- The submit handler stops the browser from reloading the page when the form is submitted.
- A FormData object reads values using the field names from index.html.
- Each site receives a temporary numeric ID so assets can point to it.
- The site selector becomes available as soon as the first site enters memory.
- Save src/main.js.
- Return to Chrome.
You should see Site form ready for a fictional record. above the empty table.
Is the site form message missing?
- Confirm that the new functions sit below the connection-banner code.
- Check that site-form and asset-site match the IDs in index.html.
Still stuck? Help me debug the site form handler and site selector in my in-memory register.
- Add the number formatter and dashboard renderer below the site logic:
function formatNumber(value) {
return Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })
}
function renderDashboard() {
const searchTerm = searchInput.value.trim().toLowerCase()
const selectedType = typeFilter.value
const sitesById = new Map(state.sites.map((site) => [site.id, site]))
// Filter every searchable site and asset detail as one text value.
const visibleAssets = state.assets.filter((asset) => {
const site = sitesById.get(asset.site_id)
const searchable = [site?.site_code, site?.site_name, site?.region, asset.asset_type, asset.manufacturer, asset.model, asset.serial_number, asset.cabinet_reference, asset.operational_status, asset.alarm_severity, asset.power_source].filter(Boolean).join(' ').toLowerCase()
return searchable.includes(searchTerm) && (selectedType === 'All' || asset.asset_type === selectedType)
})
const visibleSiteIds = new Set(visibleAssets.map((asset) => asset.site_id))
const activeAlarms = visibleAssets.filter((asset) => asset.alarm_severity !== 'None' || asset.operational_status === 'Alarm').length
const totalLoad = visibleAssets.reduce((sum, asset) => sum + Number(asset.rated_load_kw || 0), 0)
siteCount.textContent = searchTerm || selectedType !== 'All' ? visibleSiteIds.size : state.sites.length
assetCount.textContent = visibleAssets.length
alarmCount.textContent = activeAlarms
loadTotal.textContent = formatNumber(totalLoad)
recordsBody.innerHTML = visibleAssets.map((asset) => {
const site = sitesById.get(asset.site_id)
const isAlert = asset.alarm_severity !== 'None' || asset.operational_status === 'Alarm'
const capacity = asset.capacity_value !== null ? `${formatNumber(asset.capacity_value)} ${escapeHtml(asset.capacity_unit || '')}` : 'Not recorded'
return `<tr><td><strong>${escapeHtml(site?.site_code || 'Unknown')}</strong><br /><span class="muted">${escapeHtml(site?.site_name || '')}</span></td><td>${escapeHtml(asset.asset_type)}</td><td>${escapeHtml([asset.manufacturer, asset.model].filter(Boolean).join(' ') || 'Not recorded')}<br /><span class="muted">${escapeHtml(asset.serial_number || '')}</span></td><td>${escapeHtml(asset.cabinet_reference || 'Not recorded')}</td><td><span class="status-pill ${isAlert ? 'alert' : ''}">${escapeHtml(asset.operational_status)}</span></td><td>${escapeHtml(asset.alarm_severity)}</td><td>${capacity}</td><td>${escapeHtml(asset.power_source || 'Not recorded')}</td><td>${formatNumber(asset.rated_load_kw)} kW</td></tr>`
}).join('')
emptyState.hidden = visibleAssets.length > 0
emptyState.textContent = state.assets.length === 0 ? 'Add a site and equipment record to start the register.' : 'No records match the current search and filter.'
}
renderDashboard()
message.textContent = 'Dashboard ready for fictional records.'
How are dashboard totals calculated?
- The search combines site details with equipment details before checking for matching text.
- The asset-type selector removes records outside the selected category.
- A set counts each visible site once even when it has several assets.
- The summary calculations count alarms and add the electrical loads from visible records.
- Save src/main.js.
- Return to Chrome.
You should see Dashboard ready for fictional records. above the empty table. Each summary card should display 0.
Are the summary cards blank?
- Check that the renderer closes with a matching brace before renderDashboard().
- Confirm that the summary element IDs match site-count, asset-count, alarm-count, and load-total.
Still stuck? Help me debug the filtering and summary calculations in renderDashboard.
- Add the asset handler, value helpers, and filter listeners below the dashboard renderer:
function blankToNull(value) {
const cleaned = String(value ?? '').trim()
return cleaned === '' ? null : cleaned
}
function numberOrNull(value) {
const cleaned = String(value ?? '').trim()
return cleaned === '' ? null : Number(cleaned)
}
function saveAsset(event) {
event.preventDefault()
const formData = new FormData(assetForm)
// Preserve the selected site relationship with each temporary asset.
const asset = {
id: state.assets.length + 1, site_id: Number(formData.get('site_id')),
asset_type: formData.get('asset_type'), operational_status: formData.get('operational_status'),
manufacturer: blankToNull(formData.get('manufacturer')), model: blankToNull(formData.get('model')),
serial_number: blankToNull(formData.get('serial_number')), cabinet_reference: blankToNull(formData.get('cabinet_reference')),
alarm_severity: formData.get('alarm_severity'), rated_load_kw: Number(formData.get('rated_load_kw')),
capacity_value: numberOrNull(formData.get('capacity_value')), capacity_unit: blankToNull(formData.get('capacity_unit')),
power_source: blankToNull(formData.get('power_source')), notes: blankToNull(formData.get('notes')),
}
state.assets.push(asset)
assetForm.reset()
renderDashboard()
message.textContent = `Saved ${asset.asset_type} record in browser memory.`
}
assetForm.addEventListener('submit', saveAsset)
searchInput.addEventListener('input', renderDashboard)
typeFilter.addEventListener('change', renderDashboard)
How are asset records completed?
- The helper functions convert empty optional fields into null values.
- Each asset stores the selected site ID so the table can show the related site.
- The handler renders the dashboard immediately after adding an asset.
- The search and category listeners recalculate the table whenever a filter changes.
- Save src/main.js.
- Return to Chrome.
The site form, asset form, search box, category filter, summary cards, and table now share one in-memory state.
✔️ Awesome, I've got everything!
Your temporary register logic is complete. You can now add fictional sites and attach equipment records to them.
ⓧ I'd like to double check the full code
- Compare your complete src/main.js file with this reference:
// Keep references to the interface elements updated by the register.
const connectionStatus = document.querySelector('#connection-status')
const message = document.querySelector('#message')
const siteForm = document.querySelector('#site-form')
const assetForm = document.querySelector('#asset-form')
const assetSite = document.querySelector('#asset-site')
const searchInput = document.querySelector('#search-input')
const typeFilter = document.querySelector('#type-filter')
const recordsBody = document.querySelector('#records-body')
const emptyState = document.querySelector('#empty-state')
const siteCount = document.querySelector('#site-count')
const assetCount = document.querySelector('#asset-count')
const alarmCount = document.querySelector('#alarm-count')
const loadTotal = document.querySelector('#load-total')
// Store records only for the lifetime of this browser page.
const state = {
sites: [],
assets: [],
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
.replaceAll('"', '"').replaceAll("'", ''')
}
connectionStatus.textContent = 'In-memory demo active'
connectionStatus.className = 'connection-status success'
function saveSite(event) {
event.preventDefault()
const formData = new FormData(siteForm)
const site = {
id: state.sites.length + 1,
site_code: formData.get('site_code').trim(),
site_name: formData.get('site_name').trim(),
region: formData.get('region').trim(),
site_status: formData.get('site_status'),
}
state.sites.push(site)
siteForm.reset()
renderSiteOptions()
renderDashboard()
message.textContent = `Saved site ${site.site_code} in browser memory.`
}
function renderSiteOptions() {
if (state.sites.length === 0) {
assetSite.innerHTML = '<option value="">Add a site first</option>'
assetSite.disabled = true
return
}
assetSite.innerHTML = state.sites.map((site) => `<option value="${site.id}">${escapeHtml(site.site_code)} - ${escapeHtml(site.site_name)}</option>`).join('')
assetSite.disabled = false
}
siteForm.addEventListener('submit', saveSite)
renderSiteOptions()
message.textContent = 'Site form ready for a fictional record.'
function formatNumber(value) {
return Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })
}
function renderDashboard() {
const searchTerm = searchInput.value.trim().toLowerCase()
const selectedType = typeFilter.value
const sitesById = new Map(state.sites.map((site) => [site.id, site]))
// Filter every searchable site and asset detail as one text value.
const visibleAssets = state.assets.filter((asset) => {
const site = sitesById.get(asset.site_id)
const searchable = [site?.site_code, site?.site_name, site?.region, asset.asset_type, asset.manufacturer, asset.model, asset.serial_number, asset.cabinet_reference, asset.operational_status, asset.alarm_severity, asset.power_source].filter(Boolean).join(' ').toLowerCase()
return searchable.includes(searchTerm) && (selectedType === 'All' || asset.asset_type === selectedType)
})
const visibleSiteIds = new Set(visibleAssets.map((asset) => asset.site_id))
const activeAlarms = visibleAssets.filter((asset) => asset.alarm_severity !== 'None' || asset.operational_status === 'Alarm').length
const totalLoad = visibleAssets.reduce((sum, asset) => sum + Number(asset.rated_load_kw || 0), 0)
siteCount.textContent = searchTerm || selectedType !== 'All' ? visibleSiteIds.size : state.sites.length
assetCount.textContent = visibleAssets.length
alarmCount.textContent = activeAlarms
loadTotal.textContent = formatNumber(totalLoad)
recordsBody.innerHTML = visibleAssets.map((asset) => {
const site = sitesById.get(asset.site_id)
const isAlert = asset.alarm_severity !== 'None' || asset.operational_status === 'Alarm'
const capacity = asset.capacity_value !== null ? `${formatNumber(asset.capacity_value)} ${escapeHtml(asset.capacity_unit || '')}` : 'Not recorded'
return `<tr><td><strong>${escapeHtml(site?.site_code || 'Unknown')}</strong><br /><span class="muted">${escapeHtml(site?.site_name || '')}</span></td><td>${escapeHtml(asset.asset_type)}</td><td>${escapeHtml([asset.manufacturer, asset.model].filter(Boolean).join(' ') || 'Not recorded')}<br /><span class="muted">${escapeHtml(asset.serial_number || '')}</span></td><td>${escapeHtml(asset.cabinet_reference || 'Not recorded')}</td><td><span class="status-pill ${isAlert ? 'alert' : ''}">${escapeHtml(asset.operational_status)}</span></td><td>${escapeHtml(asset.alarm_severity)}</td><td>${capacity}</td><td>${escapeHtml(asset.power_source || 'Not recorded')}</td><td>${formatNumber(asset.rated_load_kw)} kW</td></tr>`
}).join('')
emptyState.hidden = visibleAssets.length > 0
emptyState.textContent = state.assets.length === 0 ? 'Add a site and equipment record to start the register.' : 'No records match the current search and filter.'
}
renderDashboard()
message.textContent = 'Dashboard ready for fictional records.'
function blankToNull(value) {
const cleaned = String(value ?? '').trim()
return cleaned === '' ? null : cleaned
}
function numberOrNull(value) {
const cleaned = String(value ?? '').trim()
return cleaned === '' ? null : Number(cleaned)
}
function saveAsset(event) {
event.preventDefault()
const formData = new FormData(assetForm)
// Preserve the selected site relationship with each temporary asset.
const asset = {
id: state.assets.length + 1, site_id: Number(formData.get('site_id')),
asset_type: formData.get('asset_type'), operational_status: formData.get('operational_status'),
manufacturer: blankToNull(formData.get('manufacturer')), model: blankToNull(formData.get('model')),
serial_number: blankToNull(formData.get('serial_number')), cabinet_reference: blankToNull(formData.get('cabinet_reference')),
alarm_severity: formData.get('alarm_severity'), rated_load_kw: Number(formData.get('rated_load_kw')),
capacity_value: numberOrNull(formData.get('capacity_value')), capacity_unit: blankToNull(formData.get('capacity_unit')),
power_source: blankToNull(formData.get('power_source')), notes: blankToNull(formData.get('notes')),
}
state.assets.push(asset)
assetForm.reset()
renderDashboard()
message.textContent = `Saved ${asset.asset_type} record in browser memory.`
}
assetForm.addEventListener('submit', saveAsset)
searchInput.addEventListener('input', renderDashboard)
typeFilter.addEventListener('change', renderDashboard)
Do form submissions reload the page?
- Confirm that both submit handlers begin with event.preventDefault().
- Check that the asset event listener appears after the complete saveAsset() function.
- Confirm that every object property is separated correctly inside the asset object.
Still stuck? Help me debug why my site or asset form reloads the page instead of saving an in-memory record.
Your register is ready for its first fictional site. Use training values so the finished table demonstrates the workflow without exposing operational data.
- Enter TRN-001 in the Site code field.
- Enter North Training Site in the Site name field.
- Enter Training Region in the Region field.
- Select Operational as the site status.
- Click Save site.
The site selector should now show TRN-001 - North Training Site. The Sites summary should increase to 1.
Your first relationship is ready
The selector uses the temporary site ID behind the visible site label. The next record carries that ID so the dashboard can recover the site's code and name.
- Select Generator as the asset type.
- Select Alarm as the operational status.
- Enter Training Vendor in the Manufacturer field.
- Enter Model-X in the Model field.
- Enter SAMPLE-001 in the Serial or asset number field.
- Enter CAB-A in the Cabinet reference field.
- Select Major as the alarm severity.
- Enter 2.5 in the Electrical load in kW field.
This sample now has enough operational detail to affect the alarm and load summaries.
- Enter 20 in the Capacity or rating field.
- Enter kVA in the Capacity unit field.
- Enter Generator in the Power source field.
- Enter Fictional training alarm in the Notes field.
- Click Save equipment record.
That is the first working slice of your asset register. You should see one generator row with an alert status plus a total load of 2.5 kW.
Before you refresh, do you think the site and generator will survive when Chrome rebuilds the page?
- Refresh Chrome to test the temporary record storage.
You should see the table return to its empty state. The summary cards return to 0 because the temporary arrays were rebuilt from scratch.
The data loss is intentional
Your forms and rendering logic work correctly. The missing record proves that browser memory cannot provide durable storage across page loads.
A persistent register needs a database that can restore the site and asset rows after the page starts again.
You now have a responsive telecom register with working forms, filters, summaries, and table rendering. Next, you will replace the disposable storage with a protected relational backend.
Add a Protected Supabase Backend
Your responsive register accepts fictional records. A Chrome refresh still erases them.
This step adds Supabase as the relational database behind the register. Authentication gives the current browser a protected demo identity.
Row Level Security limits that identity to its own rows. The temporary forms remain in memory until the next step connects them to the new tables.
In this step, get ready to:
- Create a Supabase Free project with anonymous sign-ins enabled.
- Build owner-protected site and asset tables.
- Connect the browser to an authenticated demo session.
Create the Supabase project
Anonymous sign-in creates an authenticated identity without asking for an email address. That identity becomes the owner of each fictional record created during the browser session.
Use Fictional Training Data
Telecom locations can contain operationally sensitive information. Use fictional names, alarms, serial numbers, access details, coordinates, and capacity values throughout this project.
This anonymous session is suitable for a local demonstration. Clearing browser data or moving to another device removes access to the same anonymous account.
- Visit the Supabase Dashboard in Chrome.
- Use the Dashboard project creation flow to create a Supabase Free project named telecom-site-register.
- Select the Free plan at $0/month.
The database password protects administrative access to the project. Your browser application never needs this credential.
- Create a unique database password in the project form.
- Store the password in your password manager.
- Submit the project creation form.
Supabase prepares the database after submission. A quiet provisioning screen means the project is still being prepared.
- Open the project's Auth general configuration.
- Enable Allow anonymous sign-ins.
- Apply the configuration change using the page's save control.
You should see Allow anonymous sign-ins remain enabled. The browser can now create an authenticated demo identity.
How Is an Anonymous User Protected?
Supabase anonymous users receive the authenticated Postgres role. Your database policies still decide which rows that role can read or create.
A deployed public application also needs abuse prevention for anonymous sign-ins. This local learning project stays inside a fictional browser-only demonstration.
Build the protected relational schema
A relational schema keeps sites separate from their assets. The relationship lets one site own many equipment records without duplicating its site details.
Why Two Tables?
The sites table stores one row per telecom site. The assets table stores equipment linked to a site through site_id.
A composite foreign key checks the site ID together with its owner. This prevents an asset from being attached to another user's site.
- Switch back to Visual Studio Code from earlier.
- Create a supabase folder inside telecom-site-register using the left file sidebar.
- Create schema.sql inside the new supabase folder.
- Add the site table to schema.sql by pasting this statement:
create table public.sites (
id bigint generated by default as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
site_code text not null,
site_name text not null,
region text not null,
site_status text not null check (site_status in ('Operational', 'Degraded', 'Offline', 'Maintenance')),
created_at timestamptz not null default now(),
constraint sites_user_code_unique unique (user_id, site_code),
constraint sites_id_user_unique unique (id, user_id)
);
What Does the Site Table Store?
- The identity column gives every site a database-generated primary key.
- The user_id column records which authenticated identity owns the site.
- The status check accepts only the four supported operating states.
- The unique constraints protect each owner's site codes and prepare the composite relationship used by assets.
- Save supabase/schema.sql.
- Return to the Supabase project in Chrome.
- Open the SQL Editor.
- Create a blank query.
- Paste the site table statement into the query.
- Run the query using the editor's run control.
You should see a successful query result without a SQL error. The public.sites table now exists.
Site Table Query Failed?
Confirm the query starts with create table public.sites. Check that every opening parenthesis has a matching closing parenthesis.
If the table already exists after a previous successful run, avoid running the creation statement again. Help me diagnose my site table SQL query.
- Return to supabase/schema.sql in Visual Studio Code.
- Append the asset table below the site table by pasting:
create table public.assets (
id bigint generated by default as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
site_id bigint not null,
asset_type text not null check (asset_type in ('Equipment', 'Alarm', 'Generator', 'Cabinet', 'Battery', 'Hybrid System')),
manufacturer text,
model text,
serial_number text,
cabinet_reference text,
operational_status text not null check (operational_status in ('Operational', 'Degraded', 'Alarm', 'Offline', 'Maintenance')),
alarm_severity text not null default 'None' check (alarm_severity in ('None', 'Warning', 'Major', 'Critical')),
rated_load_kw numeric(10, 2) not null default 0 check (rated_load_kw >= 0),
capacity_value numeric(10, 2) check (capacity_value is null or capacity_value >= 0),
capacity_unit text,
power_source text,
notes text,
created_at timestamptz not null default now(),
constraint assets_site_owner_fk
foreign key (site_id, user_id)
references public.sites(id, user_id)
on delete cascade
);
How Does the Asset Relationship Work?
- The asset type check matches the six categories already shown in the browser form.
- The load and capacity checks reject negative values.
- The assets_site_owner_fk relationship requires the asset owner to match the selected site owner.
- Deleting a site also deletes its attached asset rows through the cascade rule.
- Save supabase/schema.sql.
- Return to the Supabase SQL Editor.
- Create another blank query.
- Paste the asset table statement into the query.
- Run the query using the editor's run control.
You should see another successful query result. The public.assets table now exists with its owner-safe relationship to public.sites.
- Return to supabase/schema.sql.
- Append the indexes, permissions, and site policies by pasting:
create index sites_user_id_idx on public.sites using btree (user_id);
create index assets_user_id_idx on public.assets using btree (user_id);
create index assets_site_id_idx on public.assets using btree (site_id);
alter table public.sites enable row level security;
alter table public.assets enable row level security;
revoke all on table public.sites, public.assets from anon, authenticated;
grant select, insert on table public.sites, public.assets to authenticated;
create policy "Users can read their own sites"
on public.sites for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can create their own sites"
on public.sites for insert
to authenticated
with check ((select auth.uid()) = user_id);
How Are the Site Rows Protected?
- The indexes speed up ownership checks on user_id.
- Enabling RLS makes policies responsible for row access.
- The revoke statement removes existing client permissions from both browser roles.
- The grant restores only authenticated reads and inserts.
- The site policies compare the requesting identity with the row's owner.
- Save supabase/schema.sql.
- Return to the Supabase SQL Editor.
- Create another blank query.
- Paste the security statement group into the query.
- Run the query using the editor's run control.
A successful result confirms that RLS is enabled. It also confirms that the anonymous role has no table grant.
- Return to supabase/schema.sql.
- Append the asset ownership policies by pasting:
create policy "Users can read their own assets"
on public.assets for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can create assets for their own sites"
on public.assets for insert
to authenticated
with check (
(select auth.uid()) = user_id
and exists (
select 1
from public.sites
where sites.id = assets.site_id
and sites.user_id = (select auth.uid())
)
);
How Are Asset Inserts Checked?
The read policy exposes only assets owned by the current authenticated identity. The insert policy applies the same ownership check to new rows.
The nested site lookup confirms that the chosen site belongs to the same identity. This closes the cross-owner relationship gap.
- Save supabase/schema.sql.
- Return to the Supabase SQL Editor.
- Create one final blank query.
- Paste the asset policy statements into the query.
- Run the query using the editor's run control.
You should see a successful query result. Both tables now require an authenticated owner before records can be read or inserted.
Security Query Failed?
Confirm both table creation queries succeeded before running the security statements. A policy cannot attach to a table that does not exist.
Run each statement group only once because policy names must be unique. Help me diagnose my Supabase RLS schema.
✔️ Awesome, I've got everything!
Great. Confirm all four SQL query groups completed successfully and save supabase/schema.sql.
ⓧ I'd like to double check the full code
Compare your complete supabase/schema.sql file with this reference:
create table public.sites (
id bigint generated by default as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
site_code text not null,
site_name text not null,
region text not null,
site_status text not null check (site_status in ('Operational', 'Degraded', 'Offline', 'Maintenance')),
created_at timestamptz not null default now(),
constraint sites_user_code_unique unique (user_id, site_code),
constraint sites_id_user_unique unique (id, user_id)
);
create table public.assets (
id bigint generated by default as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
site_id bigint not null,
asset_type text not null check (asset_type in ('Equipment', 'Alarm', 'Generator', 'Cabinet', 'Battery', 'Hybrid System')),
manufacturer text,
model text,
serial_number text,
cabinet_reference text,
operational_status text not null check (operational_status in ('Operational', 'Degraded', 'Alarm', 'Offline', 'Maintenance')),
alarm_severity text not null default 'None' check (alarm_severity in ('None', 'Warning', 'Major', 'Critical')),
rated_load_kw numeric(10, 2) not null default 0 check (rated_load_kw >= 0),
capacity_value numeric(10, 2) check (capacity_value is null or capacity_value >= 0),
capacity_unit text,
power_source text,
notes text,
created_at timestamptz not null default now(),
constraint assets_site_owner_fk
foreign key (site_id, user_id)
references public.sites(id, user_id)
on delete cascade
);
create index sites_user_id_idx on public.sites using btree (user_id);
create index assets_user_id_idx on public.assets using btree (user_id);
create index assets_site_id_idx on public.assets using btree (site_id);
alter table public.sites enable row level security;
alter table public.assets enable row level security;
revoke all on table public.sites, public.assets from anon, authenticated;
grant select, insert on table public.sites, public.assets to authenticated;
create policy "Users can read their own sites"
on public.sites for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can create their own sites"
on public.sites for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users can read their own assets"
on public.assets for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can create assets for their own sites"
on public.assets for insert
to authenticated
with check (
(select auth.uid()) = user_id
and exists (
select 1
from public.sites
where sites.id = assets.site_id
and sites.user_id = (select auth.uid())
)
);
What Should Match?
Your file should contain both tables before the indexes and security rules. The final statements should be the two owner-only asset policies.
Connect the browser session
The database is protected before the browser sends any records. The final task is to install the client library and establish the anonymous authenticated session.
- Switch back to Visual Studio Code.
- Open package.json from the left file sidebar.
- Replace its contents with the updated package configuration:
{
"name": "telecom-site-register",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "2.118.0"
},
"devDependencies": {
"vite": "8.3.4"
}
}
What Changed in the Package File?
The @supabase/supabase-js dependency at 2.118.0 gives the browser access to Supabase authentication and database APIs. The existing Vite dependency stays at 8.3.4.
- Save package.json.
- Return to the integrated terminal that is running Vite.
- Stop the development server with Ctrl+C.
- Install the updated project dependencies by running:
npm install
What Does This Command Do?
The command reads package.json and installs the pinned Supabase client package. It also updates the local dependency lock information.
The terminal should finish without an installation error. Your project can now import the Supabase browser client.
Dependency Installation Failed?
Confirm the integrated terminal is inside the telecom-site-register folder. Check that package.json contains valid JSON with no trailing comma.
Still stuck? Help me diagnose my npm installation.
- Create .gitignore inside the telecom-site-register folder.
- Protect generated files and local environment values by pasting:
node_modules
dist
.env.local
What Does Git Ignore?
The file excludes installed packages and generated builds from source control. It also excludes the local environment file that holds your project connection values.
- Save .gitignore.
- Confirm .gitignore appears beside package.json in the file sidebar.
- Create .env.example inside the telecom-site-register folder.
- Document the required environment values by pasting:
VITE_SUPABASE_URL=your-supabase-project-url
VITE_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
Why Keep an Environment Example?
The example file documents the two values required by the application. Its placeholders make the file safe to keep with the rest of the project.
- Save .env.example.
- Confirm .env.example appears in the file sidebar.
Use Only the Publishable Key
A publishable key is designed for browser use with Row Level Security. A secret key bypasses Row Level Security and must never appear in browser code.
Check the key label before copying it. Keep database passwords and secret keys out of .env.local.
- Create .env.local beside .env.example.
- Copy the two lines from .env.example into .env.local.
- Return to the Supabase project in Chrome.
- Open the project's Connect dialog.
- Copy the Project URL.
- Replace your-supabase-project-url in .env.local with the copied URL.
- Copy the publishable key.
- Replace your-supabase-publishable-key in .env.local with the copied key.
- Save .env.local.
- Restart the Vite development server by running:
npm run dev
Why Restart Vite?
Vite loads .env.local when the development server starts. Restarting makes the two new environment values available through import.meta.env.
The terminal should print the local development URL. Keep this terminal running while you finish the browser connection.
- Return to src/main.js.
- Add the session helpers above the existing formatting helpers by pasting:
async function initializeApp() {
try {
setConnection('Creating protected demo session...')
state.session = await ensureSession()
setConnection('Protected demo session active', 'success')
} catch (error) {
setConnection('Connection failed', 'error')
showMessage(error.message, true)
}
}
async function ensureSession() {
const { data, error } = await supabase.auth.getSession()
if (error) throw error
if (data.session) return data.session
const { data: anonymousData, error: anonymousError } =
await supabase.auth.signInAnonymously()
if (anonymousError) throw anonymousError
return anonymousData.session
}
function setConnection(text, kind = '') {
connectionStatus.textContent = text
connectionStatus.className = `connection-status ${kind}`.trim()
}
How Is the Session Reused?
- The initializeApp() function updates the connection banner while authentication runs.
- The getSession() call checks for a session already stored by the browser.
- The signInAnonymously() call runs only when no stored session exists.
- The existing temporary form handlers remain responsible for records during this step.
- Save src/main.js.
- Return to Chrome at http://localhost:5173.
- Confirm the existing register interface still renders.
Register Stopped Rendering?
Check that all three functions sit outside the existing form handlers. A missing closing brace can place them inside another function.
Still stuck? Help me check the session helper placement.
- Add the Supabase import and environment constants at the very top of src/main.js by pasting:
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
What Do These Values Provide?
The import provides the official client initializer. The two constants read your project URL and publishable key from Vite's environment object.
- Save src/main.js.
- Return to Chrome.
- Confirm the register interface still renders after the hot reload.
- Find the existing state object that holds the temporary sites and assets arrays.
- Replace that object with the following client setup:
const state = {
session: null,
sites: [],
assets: [],
}
let supabase
if (!supabaseUrl || !supabasePublishableKey) {
setConnection('Add Supabase values to .env.local', 'error')
showMessage('The Supabase URL or publishable key is missing.', true)
} else {
supabase = createClient(supabaseUrl, supabasePublishableKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
},
})
initializeApp()
}
What Does the Client Setup Do?
- The session property holds the authenticated browser session.
- The missing-value check shows a useful banner when either environment value is absent.
- The client stores the session in the browser through persistSession.
- The client refreshes expiring access tokens through autoRefreshToken.
- The final call starts the authentication flow after the client exists.
- Save src/main.js.
Before you refresh Chrome, which connection message do you expect the authenticated browser session to produce?
- Refresh Chrome at http://localhost:5173.
You should see Protected demo session active in the connection banner. That message proves the browser has an authenticated Supabase session.
Connection Banner Shows an Error?
Confirm .env.local contains the Project URL beside VITE_SUPABASE_URL. Confirm the publishable key sits beside VITE_SUPABASE_PUBLISHABLE_KEY.
Restart Vite after correcting either value. Help me debug my protected Supabase session.
- Return to the Supabase SQL Editor.
- Confirm the four schema query groups show successful results.
- Confirm public.sites appears in the database table list.
- Confirm public.assets appears in the database table list.
- Reopen the security query that contains the revoke statement.
The successful security query confirms the anonymous role has no table grant. Authenticated sessions retain only the required select and insert grants under owner-only policies.
Your register now reaches a protected relational backend through an authenticated browser session. Next up, you'll replace the temporary arrays with database inserts and reads so every fictional record survives a refresh.
Persist Site and Asset Records
Your protected Supabase backend is ready. However, the forms still store each submission in temporary browser memory.
This step connects those forms to the related database tables. Your anonymous session supplies row ownership while Row Level Security keeps every user inside their own records.
In this step, get ready to:
- Load protected site and asset records from Supabase.
- Replace the temporary site and asset handlers with database inserts.
- Refresh Chrome to prove that the saved records persist.
Load protected records
The browser needs a fresh copy of both tables whenever the app starts. The site records also populate the selector that links each asset to its parent site.
- In src/main.js, add the site-selector renderer below the existing session functions by pasting this code:
function renderSiteOptions() {
const previousValue = assetSite.value
if (state.sites.length === 0) {
assetSite.innerHTML = '<option value="">Add a site first</option>'
assetSite.disabled = true
return
}
assetSite.innerHTML = state.sites
.map(
(site) =>
`<option value="${site.id}">${escapeHtml(site.site_code)} - ${escapeHtml(site.site_name)}</option>`,
)
.join('')
if (state.sites.some((site) => String(site.id) === previousValue)) {
assetSite.value = previousValue
}
assetSite.disabled = false
}
What does this function do?
- The empty-table branch keeps the selector disabled until the user saves a site.
- The mapping branch turns each site into an option containing its database ID.
- The previous selection is restored when that site still exists in the refreshed data.
- Add the database-loading function below ensureSession() by pasting this code:
async function loadData() {
showMessage('Loading protected records...')
const [sitesResult, assetsResult] = await Promise.all([
supabase
.from('sites')
.select('id, site_code, site_name, region, site_status, created_at')
.order('site_code', { ascending: true }),
supabase
.from('assets')
.select(
'id, site_id, asset_type, manufacturer, model, serial_number, cabinet_reference, operational_status, alarm_severity, rated_load_kw, capacity_value, capacity_unit, power_source, notes, created_at',
)
.order('created_at', { ascending: false }),
])
if (sitesResult.error) throw sitesResult.error
if (assetsResult.error) throw assetsResult.error
state.sites = sitesResult.data ?? []
state.assets = assetsResult.data ?? []
renderSiteOptions()
renderDashboard()
showMessage('')
}
How does loading work?
- Promise.all() starts both protected table queries during the same loading cycle.
- The site query orders records by site_code so the selector remains predictable.
- The asset query places the newest records first by ordering on created_at.
- The returned rows replace the arrays in state before the interface renders.
- Replace the existing initializeApp() function with this version:
async function initializeApp() {
try {
setConnection('Creating protected demo session...')
state.session = await ensureSession()
setConnection('Protected demo session active', 'success')
await loadData()
} catch (error) {
setConnection('Connection failed', 'error')
showMessage(error.message, true)
}
}
Why load after authentication?
The database policies compare each row owner with the authenticated user. Waiting for ensureSession() gives every protected query the session it needs.
- Save src/main.js.
- Return to Chrome to check the refreshed interface.
You should see Protected demo session active in the connection banner. If the database has no rows yet, the equipment selector remains disabled with Add a site first.
Does the connection fail during loading?
- Check that loadData() is inside the try block in initializeApp().
- Check that both table names are lowercase plural values matching sites and assets.
- Check the browser console for the database message returned through showMessage().
Still stuck? Help me debug why loadData cannot retrieve my Supabase sites and assets.
Save sites to Supabase
A site must exist before an asset can reference it. The site insert includes the current session user ID so the ownership policy can approve the new row.
- In src/main.js, replace the temporary saveSite() function with this database-backed version:
async function saveSite(event) {
event.preventDefault()
if (!state.session) return
const formData = new FormData(siteForm)
const site = {
user_id: state.session.user.id,
site_code: formData.get('site_code').trim(),
site_name: formData.get('site_name').trim(),
region: formData.get('region').trim(),
site_status: formData.get('site_status'),
}
try {
setFormDisabled(siteForm, true)
const { error } = await supabase.from('sites').insert(site)
if (error) throw error
siteForm.reset()
await loadData()
showMessage(`Saved site ${site.site_code}.`)
} catch (error) {
showMessage(error.message, true)
} finally {
setFormDisabled(siteForm, false)
}
}
What does this handler do?
- The handler reads the site form after stopping its normal page submission.
- The user_id value links the new row to the anonymous authenticated user.
- The form stays disabled while the insert is running to prevent duplicate submissions.
- A successful insert reloads both tables so the new site immediately reaches the asset selector.
- Save src/main.js.
- Enter TRN-001 in the Site code field.
- Enter North Training Site in the Site name field.
- Enter Training Region in the Region field.
- Leave Site status set to Operational.
- Click Save site.
That is the first persistent row working. You should see Saved site TRN-001. above the table.
The equipment selector should now show TRN-001 - North Training Site. Its hidden option value is the site's database ID.
Does the site refuse to save?
- Confirm that the connection banner still shows the protected session as active.
- Use a new fictional site code if you already saved TRN-001 for this anonymous user.
- Check that user_id comes from state.session.user.id.
Need a hand? Help me diagnose why my Supabase site insert is failing.
Save assets and prove persistence
Each asset stores the selected site's ID as its relationship. The existing blankToNull() and numberOrNull() helpers keep optional blank fields compatible with the database.
- Update the existing saveAsset() declaration so the function is asynchronous.
- Keep the existing submission prevention at the top of the function.
- Add an early return when state.session is unavailable.
- Replace the temporary asset object inside saveAsset() with this database payload:
const formData = new FormData(assetForm)
const asset = {
user_id: state.session.user.id,
site_id: Number(formData.get('site_id')),
asset_type: formData.get('asset_type'),
manufacturer: blankToNull(formData.get('manufacturer')),
model: blankToNull(formData.get('model')),
serial_number: blankToNull(formData.get('serial_number')),
cabinet_reference: blankToNull(formData.get('cabinet_reference')),
operational_status: formData.get('operational_status'),
alarm_severity: formData.get('alarm_severity'),
rated_load_kw: Number(formData.get('rated_load_kw')),
capacity_value: numberOrNull(formData.get('capacity_value')),
capacity_unit: blankToNull(formData.get('capacity_unit')),
power_source: blankToNull(formData.get('power_source')),
notes: blankToNull(formData.get('notes')),
}
How is the asset prepared?
- The selected site_id becomes a number that points to the parent site row.
- The authenticated user ID lets the composite foreign key confirm that the asset owner also owns the selected site.
- Required load data becomes a number while optional capacity data can remain null.
- Optional text fields become null when the learner leaves them blank.
- Replace the temporary save logic below the asset payload with this insert cycle:
try {
setFormDisabled(assetForm, true)
const { error } = await supabase.from('assets').insert(asset)
if (error) throw error
assetForm.reset()
await loadData()
showMessage(`Saved ${asset.asset_type} record.`)
} catch (error) {
showMessage(error.message, true)
} finally {
setFormDisabled(assetForm, false)
assetSite.disabled = state.sites.length === 0
}
How does the insert finish?
- The asset form stays disabled until the database responds.
- A successful insert clears the form before reloading the protected records.
- The final selector state depends on whether at least one site remains available.
- Save src/main.js.
- Select TRN-001 - North Training Site in the Site field.
- Leave Asset type set to Equipment.
- Enter Training Vendor in the Manufacturer field.
- Enter Model-X in the Model field.
- Enter SAMPLE-001 in the Serial or asset number field.
- Enter 1.25 in the Electrical load in kW field.
- Click Save equipment record.
You should see the new Equipment row in the table. The Assets card should show 1 while Total load should show 1.25 kW.
Before you refresh, consider this question: do you expect the saved site relationship and equipment row to survive.
- Refresh Chrome.
You should see the same site and Equipment row return after the protected session reconnects. The connection banner should still read Protected demo session active.
What did the refresh prove?
The rows now live in the database instead of a temporary JavaScript array. The persisted browser session identifies the same anonymous user after the refresh.
The owner-only policies apply that user ID to every read. This keeps the current browser session inside its own protected register.
Does the asset disappear after refreshing?
- Check that saveAsset() awaits the insert before resetting the form.
- Check that loadData() assigns the returned asset rows to state.assets.
- Check that the browser still shows the same protected demo session after the refresh.
Still missing the row? Help me trace why my saved Supabase asset does not return after a Chrome refresh.
✔️ Awesome, I've got everything!
Your site and asset now return after a Chrome refresh. The protected register is reading from Supabase instead of temporary browser memory.
- Confirm that you saved src/main.js.
- Confirm that the connection banner shows Protected demo session active.
ⓧ I'd like to double check the full code
The complete src/main.js file should match this reference.
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
const connectionStatus = document.querySelector('#connection-status')
const message = document.querySelector('#message')
const siteForm = document.querySelector('#site-form')
const assetForm = document.querySelector('#asset-form')
const assetSite = document.querySelector('#asset-site')
const searchInput = document.querySelector('#search-input')
const typeFilter = document.querySelector('#type-filter')
const recordsBody = document.querySelector('#records-body')
const emptyState = document.querySelector('#empty-state')
const siteCount = document.querySelector('#site-count')
const assetCount = document.querySelector('#asset-count')
const alarmCount = document.querySelector('#alarm-count')
const loadTotal = document.querySelector('#load-total')
const state = {
session: null,
sites: [],
assets: [],
}
let supabase
if (!supabaseUrl || !supabasePublishableKey) {
setConnection('Add Supabase values to .env.local', 'error')
showMessage('The Supabase URL or publishable key is missing.', true)
} else {
supabase = createClient(supabaseUrl, supabasePublishableKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
},
})
initializeApp()
}
siteForm.addEventListener('submit', saveSite)
assetForm.addEventListener('submit', saveAsset)
searchInput.addEventListener('input', renderDashboard)
typeFilter.addEventListener('change', renderDashboard)
async function initializeApp() {
try {
setConnection('Creating protected demo session...')
state.session = await ensureSession()
setConnection('Protected demo session active', 'success')
await loadData()
} catch (error) {
setConnection('Connection failed', 'error')
showMessage(error.message, true)
}
}
async function ensureSession() {
const { data, error } = await supabase.auth.getSession()
if (error) throw error
if (data.session) return data.session
const { data: anonymousData, error: anonymousError } =
await supabase.auth.signInAnonymously()
if (anonymousError) throw anonymousError
return anonymousData.session
}
async function loadData() {
showMessage('Loading protected records...')
const [sitesResult, assetsResult] = await Promise.all([
supabase
.from('sites')
.select('id, site_code, site_name, region, site_status, created_at')
.order('site_code', { ascending: true }),
supabase
.from('assets')
.select(
'id, site_id, asset_type, manufacturer, model, serial_number, cabinet_reference, operational_status, alarm_severity, rated_load_kw, capacity_value, capacity_unit, power_source, notes, created_at',
)
.order('created_at', { ascending: false }),
])
if (sitesResult.error) throw sitesResult.error
if (assetsResult.error) throw assetsResult.error
state.sites = sitesResult.data ?? []
state.assets = assetsResult.data ?? []
renderSiteOptions()
renderDashboard()
showMessage('')
}
async function saveSite(event) {
event.preventDefault()
if (!state.session) return
const formData = new FormData(siteForm)
const site = {
user_id: state.session.user.id,
site_code: formData.get('site_code').trim(),
site_name: formData.get('site_name').trim(),
region: formData.get('region').trim(),
site_status: formData.get('site_status'),
}
try {
setFormDisabled(siteForm, true)
const { error } = await supabase.from('sites').insert(site)
if (error) throw error
siteForm.reset()
await loadData()
showMessage(`Saved site ${site.site_code}.`)
} catch (error) {
showMessage(error.message, true)
} finally {
setFormDisabled(siteForm, false)
}
}
async function saveAsset(event) {
event.preventDefault()
if (!state.session) return
const formData = new FormData(assetForm)
const asset = {
user_id: state.session.user.id,
site_id: Number(formData.get('site_id')),
asset_type: formData.get('asset_type'),
manufacturer: blankToNull(formData.get('manufacturer')),
model: blankToNull(formData.get('model')),
serial_number: blankToNull(formData.get('serial_number')),
cabinet_reference: blankToNull(formData.get('cabinet_reference')),
operational_status: formData.get('operational_status'),
alarm_severity: formData.get('alarm_severity'),
rated_load_kw: Number(formData.get('rated_load_kw')),
capacity_value: numberOrNull(formData.get('capacity_value')),
capacity_unit: blankToNull(formData.get('capacity_unit')),
power_source: blankToNull(formData.get('power_source')),
notes: blankToNull(formData.get('notes')),
}
try {
setFormDisabled(assetForm, true)
const { error } = await supabase.from('assets').insert(asset)
if (error) throw error
assetForm.reset()
await loadData()
showMessage(`Saved ${asset.asset_type} record.`)
} catch (error) {
showMessage(error.message, true)
} finally {
setFormDisabled(assetForm, false)
assetSite.disabled = state.sites.length === 0
}
}
function renderSiteOptions() {
const previousValue = assetSite.value
if (state.sites.length === 0) {
assetSite.innerHTML = '<option value="">Add a site first</option>'
assetSite.disabled = true
return
}
assetSite.innerHTML = state.sites
.map(
(site) =>
`<option value="${site.id}">${escapeHtml(site.site_code)} - ${escapeHtml(site.site_name)}</option>`,
)
.join('')
if (state.sites.some((site) => String(site.id) === previousValue)) {
assetSite.value = previousValue
}
assetSite.disabled = false
}
function renderDashboard() {
const searchTerm = searchInput.value.trim().toLowerCase()
const selectedType = typeFilter.value
const sitesById = new Map(state.sites.map((site) => [site.id, site]))
const visibleAssets = state.assets.filter((asset) => {
const site = sitesById.get(asset.site_id)
const searchable = [
site?.site_code,
site?.site_name,
site?.region,
asset.asset_type,
asset.manufacturer,
asset.model,
asset.serial_number,
asset.cabinet_reference,
asset.operational_status,
asset.alarm_severity,
asset.power_source,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
const matchesSearch = searchable.includes(searchTerm)
const matchesType = selectedType === 'All' || asset.asset_type === selectedType
return matchesSearch && matchesType
})
const visibleSiteIds = new Set(visibleAssets.map((asset) => asset.site_id))
const activeAlarms = visibleAssets.filter(
(asset) =>
asset.alarm_severity !== 'None' || asset.operational_status === 'Alarm',
).length
const totalLoad = visibleAssets.reduce(
(sum, asset) => sum + Number(asset.rated_load_kw || 0),
0,
)
siteCount.textContent = searchTerm || selectedType !== 'All'
? visibleSiteIds.size
: state.sites.length
assetCount.textContent = visibleAssets.length
alarmCount.textContent = activeAlarms
loadTotal.textContent = formatNumber(totalLoad)
recordsBody.innerHTML = visibleAssets
.map((asset) => {
const site = sitesById.get(asset.site_id)
const isAlert =
asset.alarm_severity !== 'None' || asset.operational_status === 'Alarm'
const capacity = asset.capacity_value
? `${formatNumber(asset.capacity_value)} ${escapeHtml(asset.capacity_unit || '')}`
: 'Not recorded'
return `
<tr>
<td>
<strong>${escapeHtml(site?.site_code || 'Unknown')}</strong><br />
<span class="muted">${escapeHtml(site?.site_name || '')}</span>
</td>
<td>${escapeHtml(asset.asset_type)}</td>
<td>
${escapeHtml([asset.manufacturer, asset.model].filter(Boolean).join(' ') || 'Not recorded')}<br />
<span class="muted">${escapeHtml(asset.serial_number || '')}</span>
</td>
<td>${escapeHtml(asset.cabinet_reference || 'Not recorded')}</td>
<td><span class="status-pill ${isAlert ? 'alert' : ''}">${escapeHtml(asset.operational_status)}</span></td>
<td>${escapeHtml(asset.alarm_severity)}</td>
<td>${capacity}</td>
<td>${escapeHtml(asset.power_source || 'Not recorded')}</td>
<td>${formatNumber(asset.rated_load_kw)} kW</td>
</tr>
`
})
.join('')
emptyState.hidden = visibleAssets.length > 0
emptyState.textContent =
state.assets.length === 0
? 'Add a site and equipment record to start the register.'
: 'No records match the current search and filter.'
}
function setConnection(text, kind = '') {
connectionStatus.textContent = text
connectionStatus.className = `connection-status ${kind}`.trim()
}
function showMessage(text, isError = false) {
message.textContent = text
message.className = isError ? 'message error' : 'message'
}
function setFormDisabled(form, disabled) {
form.querySelectorAll('button, input, select, textarea').forEach((element) => {
element.disabled = disabled
})
}
function blankToNull(value) {
const cleaned = String(value ?? '').trim()
return cleaned === '' ? null : cleaned
}
function numberOrNull(value) {
const cleaned = String(value ?? '').trim()
return cleaned === '' ? null : Number(cleaned)
}
function formatNumber(value) {
return Number(value || 0).toLocaleString(undefined, {
maximumFractionDigits: 2,
})
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
}
Your register now stores related site and asset records behind an authenticated session. Next up, you will turn those persistent rows into an operations dashboard for search, alarms, and electrical load.
Build the Operations Dashboard
Your fictional records now survive a refresh in Chrome. Supabase keeps each record tied to the persisted anonymous session.
Persistent records become operationally useful when an operator can find a site quickly. Alarms and electrical demand also need to stay visible.
You will use client-side filtering to narrow the dashboard. The summary cards will recalculate from only the visible records.
In this step, get ready to:
- Add a detailed generator record for the dashboard.
- Add an alarmed battery record with a nonzero load.
- Test the search box, asset-type filter, summary cards, and empty state.
Add a detailed generator record
A unique manufacturer label gives both test records a shared search target. Detailed values also prove that each operational column renders correctly.
- Switch back to the register in Chrome.
- Confirm the connection banner reads Protected demo session active.
- Scroll to the Add site equipment form.
- Select your existing fictional site in the Site field.
- Select Generator in the Asset type field.
- Enter Dashboard Test in the Manufacturer field.
- Enter GEN-200 in the Model field.
- Enter DASH-GEN-001 in the Serial or asset number field.
Why use a shared test label?
Both records use Dashboard Test as their manufacturer. One search can therefore isolate the pair for a predictable dashboard check.
- Enter PWR-A in the Cabinet reference field.
- Select Operational in the Operational status field.
- Select None in the Alarm severity field.
- Enter 4.5 in the Electrical load in kW field.
- Enter 100 in the Capacity or rating field.
- Enter kVA in the Capacity unit field.
- Enter Diesel in the Power source field.
- Click Save equipment record.
You should see Saved Generator record. above the table. The new generator row shows a load of 4.5 kW.
Generator row missing?
- Check the message above the table for a database error.
- Clear any value left in the search box.
- Set the asset-type filter to All asset types.
Still stuck? Help me find why my saved generator is missing from the dashboard.
Add an alarmed battery record
The second record gives the dashboard a contrasting operational state. Its alarm and load make changes to the summary cards easy to verify.
- Select the same fictional site in the Site field.
- Select Battery in the Asset type field.
- Enter Dashboard Test in the Manufacturer field.
- Enter BAT-500 in the Model field.
- Enter DASH-BAT-001 in the Serial or asset number field.
- Enter PWR-B in the Cabinet reference field.
- Select Alarm in the Operational status field.
- Select Major in the Alarm severity field.
What makes an alarm active?
The dashboard counts a record when alarm_severity differs from None. It also counts a record when operational_status equals Alarm.
- Enter 1.75 in the Electrical load in kW field.
- Enter 200 in the Capacity or rating field.
- Enter Ah in the Capacity unit field.
- Enter Battery in the Power source field.
- Click Save equipment record.
You should see Saved Battery record. above the table. The battery row uses the alert styling because it has an active alarm.
Test search, filters, and empty states
Every change to searchInput or typeFilter calls renderDashboard(). That function filters state.assets before rebuilding the table and summary cards.
- Click the Search records field.
- Enter GEN-200.
You should see only the generator row. The cards should show one site, one asset, zero active alarms, and a total load of 4.5 kW.
- Clear the Search records field.
- Select Battery in the asset-type filter.
The generator row leaves the table. The BAT-500 row remains visible while the alarm and load cards recalculate.
- Enter NO-MATCH-999 in the Search records field.
You should see No records match the current search and filter. beneath the table. The visible site count, asset count, alarm count, and load should all show zero.
Before you apply the final search, consider the four totals you expect these two records to produce.
- Clear the Search records field.
- Select All asset types in the asset-type filter.
- Enter Dashboard Test in the Search records field.
You should see the generator and battery rows. The cards should show one visible site, two visible assets, one active alarm, and a total load of 6.25 kW.
What does the final view prove?
- Each row connects its asset to the selected site code and site name.
- The identity area displays the manufacturer, model, serial number, and cabinet reference.
- The operational columns display status, alarm severity, capacity, power source, and rated load.
- The summary cards calculate their values from only the records that remain visible.
Summary totals look different?
- Check that both records use the exact manufacturer value Dashboard Test.
- Check that the generator load is 4.5.
- Check that the battery load is 1.75.
Still stuck? Help me debug why my filtered dashboard totals do not match the visible records.
That is the operations view working from end to end. Your protected records can now be searched, filtered, checked for alarms, and measured by total electrical load.
Secret mission
Audit a Hybrid-Power Site
Populate a fictional hybrid-power site with a generator, battery, and hybrid system. Use the filtered operations dashboard to identify the asset with the strongest combination of alarm severity, operational condition, electrical load, and recorded capacity.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the register available, pause its local process, or remove every project resource. The local Vite server has no cloud cost, while the Supabase Free plan costs $0 per month.
Resources you used:
- The running Vite development server for telecom-site-register.
- The local telecom-site-register folder, including .env.local and the remaining project files.
- The Supabase Free project, including the Row Level Security protected sites and assets tables.
- The anonymous demo users and fictional records, including HYB-001 Training Site with its Generator, Battery, and Hybrid System records.
Keep everything running
Keep everything in place if you are still developing the register. No cleanup is required.
- Leave the Vite development server running while you continue testing the register.
- Revisit the Supabase project periodically if you want to avoid an inactivity pause.
- Export any fictional records you want to preserve because Free projects do not include automatic backups.
Supabase can pause the Free project after one week of inactivity. You can resume it when you return.
Pause - I'll come back to this later
Stop the local development process to free system resources while preserving the project for another session.
- Switch back to the Visual Studio Code terminal from earlier.
- Press Ctrl+C to stop the Vite development server.
Your terminal prompt returns, confirming that the local Vite process has stopped.
Your local files, Supabase project, protected tables, browser session, and fictional records remain in place. The Free project can be resumed if inactivity causes it to pause.
Delete - I don't want to use this again
Remove the Supabase project and local project folder when you are finished with the register.
Deletion is permanent. The first check helps protect any fictional records you want to revisit.
Stop the local development server.
- Switch back to the Visual Studio Code terminal from earlier.
- Press Ctrl+C to stop the Vite development server.
Your terminal prompt returns, confirming that the server is no longer running.
Delete the Supabase project.
- Confirm that you have exported any fictional records you want to keep.
- Return to the project in the Supabase Dashboard.
- Open the project's settings page.
- Select the option to delete the Supabase project.
- Complete the on-screen confirmation prompt.
You should no longer see the project in the Supabase Dashboard. Its tables, anonymous demo users, and fictional records have been removed.
Delete the local project folder.
- Confirm that .env.local is inside the telecom-site-register folder.
- Close Visual Studio Code.
- Use Windows search to open File Explorer.
- Return to the location where you created telecom-site-register.
- Select the telecom-site-register folder.
- Delete the selected folder.
You should no longer see telecom-site-register in File Explorer. Deleting the folder also removes .env.local and the remaining local project files.
Nice Work!
Nice Work!
You did it! Your browser-based telecom site register now stores protected fictional infrastructure records in Supabase.
Your register restores its data after a browser refresh. The dashboard also turns those records into an operational view of alarms and electrical demand.
You've learned how to:
- Built a telecom data-entry workflow with dedicated forms for fictional sites and related equipment.
- Created a relational data model that keeps each asset attached to its owning site. Used Row Level Security to enforce ownership for each authenticated session.
- Built an operations dashboard that narrows stored records through search or asset type. Its summary cards recalculate for the visible records. Alarmed records remain highlighted inside the operational table.
- Completed the Secret Mission by populating HYB-001 Training Site with a Generator record. Added a Battery record. Added a Hybrid System record. Compared alarm severity against operational status. Compared electrical load against recorded capacity. Used that evidence to identify the highest-priority power risk.
Ready to quiz yourself?