How Amber Compare & Export is put together, for contributors and reviewers. User-facing features are in the README; the UI contract for a redesign is in FRONTEND.md; review notes are in REVIEW.md.
- A static site (GitHub Pages) with one page,
index.html. There is no server, no backend and no account. Network traffic is limited to Amber's API (https://api.amber.com.au/v1) and the site's own files (data/…), enforced by a Content Security Policy inindex.html. - Plain JavaScript, no framework and no bundler. Each file in
js/is an IIFE that adds to a single global namespace,window.Amber(orglobalThis.Amberunder Node). The files are classic<script>tags loaded in a fixed order, sofile://works. - Logic and UI are mostly separate. Calculation modules (
costs,monthly,estimate,redispatch,planner,solar,stats,bill,import,time,templates,networks) don't touch the DOM and are unit-tested in Node. DOM code lives in the page controller (app-*.jsandapp.js: everything except the planner),planner-ui.js(the Solar+? tab),charts.js,calendar.js,help.jsand parts ofcsv.jsandstorage.js. - Styling is Tailwind CSS 3, prebuilt by the Tailwind CLI from
css/input.cssintocss/app.css(committed). There's no runtime CSS-in-JS and no inline<style>. - Vendored libraries in the repository root: Chart.js 4.4.2 (
chart.umd.min.js), chartjs-plugin-zoom (chartjs-plugin-zoom.min.js) and pdf.js (pdf.min.js,pdf.worker.min.js, loaded on demand for bill PDFs). - Storage: IndexedDB for meter readings (a per-site, per-day cache), localStorage for settings, saved plans, imported households and the last bill check.
index.html The whole page: header, dates bar, tab bar, six tab panels, footer, script tags
css/input.css Tailwind source (base layer + a few component classes)
css/app.css Built CSS (npm run build:css), committed
js/ Application code (see the module map)
data/prices/<REGION>/ AEMO 5-minute wholesale prices, one JSON per month (NSW1, QLD1, SA1, VIC1)
data/solar/<town>.json PVGIS typical-year hourly weather for 22 towns
scripts/static-server.js Tiny static server for development and Playwright (port 4173)
scripts/update-prices.js Downloads AEMO prices into data/prices (run daily by a GitHub Action)
scripts/update-solar.js Downloads PVGIS weather into data/solar (run by hand)
tests/unit-*.js Node unit checks (no framework: assert + console)
tests/*.spec.js Playwright end-to-end tests (Chromium)
eslint.config.js ESLint settings (npm run lint)
eslint-rules/ The project's own ESLint rules (no-extra-arguments)
.github/workflows/ test.yml (lint and tests on PRs and main), update-prices.yml (daily price refresh)
chart.umd.min.js, chartjs-plugin-zoom.min.js, pdf.min.js, pdf.worker.min.js Vendored libraries
index.html loads, in order:
chart.umd.min.js, chartjs-plugin-zoom.min.js
js/constants.js js/help.js js/time.js js/templates.js js/storage.js
js/costs.js js/api.js js/bill.js js/csv.js js/charts.js
js/calendar.js js/stats.js js/monthly.js js/redispatch.js js/import.js
js/networks.js js/estimate.js js/solar.js js/planner.js js/planner-ui.js
js/app-core.js js/app-plan-form.js js/app-results.js js/app-imports.js
js/app-bill.js js/app-free.js js/app-settings.js
js/app.js
Every file starts with const Amber = root.Amber = root.Amber || {}; and attaches functions and constants as Amber.something. Later files may use anything earlier files define. app.js is last and starts the page (init() on DOMContentLoaded). Every script URL carries ?v=<version> to bust caches. tests/unit-syntax.js checks that every file in js/ has its script tag, that app-core.js is the first of the app files and that app.js is last.
Under Node (unit tests), files are required in the same order and attach to global.Amber; DOM-free modules work unchanged.
Everything in the UI except the Solar+? tab is one controller, split over eight files by area (the module map lists them). They work on one state object and call each other's functions, so they share an internal namespace, Amber.app:
app-core.jsloads first and creates it:Amber.app = { state, $, … }, withstate(what the page remembers while it is open: the templates, the last Compare's channel totals, sites, the bill check and so on),$(getElementById) and the helpers every file reads (the selected site and state, Amber's fixed charges, saved JSON, messages).- Each file starts with
const app = Amber.app; const state = app.state; const $ = app.$;. Functions in the same file are called directly. A function in another file is called asapp.name(…), looked up when the call runs, so the files can call each other in both directions and onlyapp-core.jshas to come before the rest. - Each file ends with
Object.assign(app, { … }), listing only what another file uses. That list is the file's interface: a function that isn't on it is private to its file. To use a function from another file, add it to that file's list and call it asapp.name(). - Each area has a
setup…function that adds its event listeners (setupPlanForm,setupImports,setupBillCheck,setupFreeTab,setupSettingsMenu;setupTabs,setupStartTab,setupAccountandsetupResultToolsinapp.js).init()inapp.jscalls them in order.
Two checks keep this honest. ESLint's no-undef reports a call to another file's function that was written without app., and tests/unit-syntax.js checks that every app.<name> used is put on Amber.app by exactly one file, that everything on it is used by another file, and that no module outside the app files touches it.
Amber.app is internal to those files. The hooks for everything else are:
Amber.appApi(set inapp.js) givesplanner-ui.jswhat it needs from the main app:selectedSite,currentStateCode,readAmberRates,templates(),selectedPlanName()andselectTab. The planner reads the meter cache itself throughstorage.js.Amber.plannerUi = { show, run }(set inplanner-ui.js) is called byapp.jswhen the Solar+? tab opens.Amber._appState(set inapp-core.js) is the samestateobject, for tests and debugging.
| File | Lines | Owns |
|---|---|---|
constants.js |
~130 | API URL and fetch limits, app version, Amber's default fixed charges (this year and last), state time zones, network names and plan-to-network matching, plan company names (planCompany) |
help.js |
~310 | Amber.HELP (every help text, keyed) and the help-bubble behaviour for any [data-help] element |
time.js |
~250 | Dates and times: NEM time (UTC+10) vs local wall clock, interval end times, TOU window matching, day arithmetic |
templates.js |
~570 | Built-in retailer plans per state (supplierTemplates), with "as at" dates |
storage.js |
~220 | IndexedDB cache (AmberDataCacheDB → dailyUsage, key <siteId>_<YYYY-MM-DD>), cache freshness rules, API key storage (obfuscated), escapeHTML |
costs.js |
~910 | Pricing a period: per-channel totals from usage items, TOU periods, other plans' costs (flat/TOU, controlled load, feed-in windows and caps, free windows), demand tariffs (Amber and other plans), Amber fixed charges, Ausgrid export settlement, computePlanTotals |
api.js |
~230 | Amber API client: sites, usage in date chunks, timeouts, retries with backoff, a small promise pool |
bill.js |
~360 | Reading an Amber bill (PDF via pdf.js, or text): rates, period, line items; compareBill checks it against meter data |
csv.js |
~85 | CSV downloads: every reading (a versioned, restorable backup), daily totals, the results table |
charts.js |
~440 | Usage tab charts (24-hour average, daily) with Chart.js plugins |
calendar.js |
~360 | Usage tab calendar and its day tooltips |
stats.js |
~60 | More Stats figures (price spikes, negative prices, evening share, grid-free days) |
monthly.js |
~130 | periodTotals / monthlyBreakdown: Amber vs another plan per calendar month, worked out the same way as Compare |
redispatch.js |
~350 | Free? tab: re-timing real interval data into a plan's free and feed-in windows under daily shift budgets |
import.js |
~380 | Amber usage CSV import into the cache (API-shaped items, demand window flags) |
networks.js |
~280 | Network tariffs and Amber's charges for all 11 networks Amber serves (FY2026-27), for the NEM12 estimate |
estimate.js |
~400 | NEM12 parsing and the rebuild of Amber's interval prices from wholesale prices, as a local "site" |
solar.js |
~220 | Towns, PVGIS weather loading, sun position, panel output per half hour (tilt, facing, shade, temperature, inverter clipping) |
planner.js |
~1,480 | Solar+? engine: the half-hourly year, plan rate cards, exact costing, flows, existing-battery detection and unwinding, battery simulation, the search, finance and ratings |
planner-ui.js |
~1,190 | Solar+? tab UI: inputs and settings, running the search, every results card |
app-core.js |
~200 | What the page controller's files share: state, Amber.app, $, storedJson, the selected site and state, Amber's fixed charges (loadAmberChargeFields, readAmberRates), fmtDate, the message under the dates bar, tables in their own sideways-scrolling box (scrollBox, syncScrollHints) |
app-plan-form.js |
~620 | The plan form on Compare: TOU and feed-in windows, the plan picker (updatePlanSelector, selectSupplierPlan), createPlanObjectFromForm, applyPlanTemplate, saved settings (saveAllSettings, loadAllSettings), custom plans |
app-results.js |
~690 | Showing results: the answer (displayResults), every plan (renderAllPlansTable), savings in the plan picker (updatePlanSavings), month by month (loadMonthlyData, renderMonthly), the Usage tab's daily graph, day picker and More Stats, hideResults |
app-imports.js |
~450 | Sites and imports: the site picker (populateSites, syncSiteMode), imported households (loadLocalSites, renderLocalSites), Amber CSV import (handleUsageCsvFile), NEM12 (handleNem12File, renderNem12Options, buildNem12Estimate) |
app-bill.js |
~300 | An Amber bill: reading the file and filling Amber's charges from it (handleAmberBillFile, applyAmberBillRates), and the Bill? tab, which loads the bill period's own readings (ensureBillLoaded, billChannelData, renderBillCheck) |
app-free.js |
~250 | The Free? tab: its settings, renderBatteryShift, drawBatteryShift |
app-settings.js |
~120 | The settings menu: buildSettingsMenu, clearSavedReadings, deleting custom plans |
app.js |
~720 | The top of the controller, loaded last: tabs (selectTab), the Start tab's welcome and account fields, the dates bar and Download menu, loadReadings (the cache, then Amber), fetchAndCompare, recalculateAndShow, init(), Amber.appApi |
A site is either Amber's (from GET /sites) or local, an imported NEM12 household ({ id, local: true, nmi, network, channels, … }, kept in localStorage localSites). Channels have identifier and type (general, controlledLoad, feedIn) and a tariff code.
The unit of meter data is the Amber API's usage item, one per channel per interval:
{ channelIdentifier, channelType, nemTime (interval end, NEM time), startTime, endTime, duration (min),
date (NEM date), kwh, perKwh (c/kWh inc GST; feed-in negative = credit), cost, quality,
spikeStatus, renewables, tariffInformation: { demandWindow, … } }
Imports (Amber CSV, NEM12 estimate) produce the same shape, so every feature works on any source.
IndexedDB AmberDataCacheDB, store dailyUsage, one record per site and NEM date (id = "<siteId>_<date>"). Connections aren't closed after use; each closes itself on versionchange, so "Clear saved meter readings" (deleteDatabase) isn't blocked. Every transaction rejects its promise on abort as well as error (a full disk aborts without an error event), and a Compare whose readings can't be saved still shows its results. Compare reads the cache first and fetches only missing or stale days, in 7-day chunks, 3 at a time (Amber.FETCH_CHUNK_DAYS, FETCH_CONCURRENCY). Final whole days are cached. Old days with no data are remembered as empty (emptyDayCutoff). Estimated readings stay eligible for refetch (cachedDayUsable).
Amber.processUsageData(items, channelTotals) turns items into per-channel totals plus the per-interval detail the cost functions need. Amber.emptyChannel(c) creates one entry per channel. Everything on Compare, Usage and Bill? is computed from these.
A plan (built-in template or saved custom plan):
{ rateType: 'flat' | 'tou', flat, daily (c/day), feedIn, cl (controlled load c/kWh), clock: 'local' | 'nem',
tou: { free: { rate, capKwh, windows }, superOffpeak, peak: { rate, windows: [{ start, end, days }] },
shoulder, offpeak: { rate } },
feedInWindows: [{ rate, start, end, days, capKwh, overRate }],
demand: { e (enabled), … window and rate fields }, asAt }
TOU period names and labels are in Amber.PERIOD_LABELS. Days are 0 = Sunday. Rates in templates are GST-inclusive c/kWh and c/day; feed-in is the published rate. templates.js has the full set; app-plan-form.js createPlanObjectFromForm builds one from the plan form.
Amber.planYear(byDate) turns the cache into a 365-day, half-hourly year (n = 365 × 48 slots, NEM time):
{ ok, dates[365], n, days, imp, exp, cl (kWh per slot), amberImp, amberCl, amberExp (c/kWh, negative = credit),
demandFlag, filled, first, last, meterExp (after an existing battery is unwound), … }
Missing days (up to 31) are filled from the nearest whole day on the same weekday. Every plan's rate windows start on the hour or half hour, so half-hourly pricing equals pricing the 5-minute readings: the planner's cost of the unchanged year matches Compare exactly. tests/unit-planner.js checks this.
Start (app.js: setupStartTab, setupAccount; app-imports.js: sites and imports; app-core.js: Amber's charges)
- API key:
Amber.setApiKeystores an obfuscated key in localStorage (or sessionStorage);Amber.fetchSiteslists sites. The state selector follows the site's network (siteNetworkState, fromAmber.NETWORKS) when the state showing has no plans for it, unless the user picked the state by hand for that site. - Amber's fixed charges:
loadAmberChargeFields/readAmberRates. The two years' connection, subscription and demand rates are split atAmber.AMBER_RATES_CHANGE_DATE(1 July 2026). Uploading a bill (handleAmberBillFile→bill.js) fills the year it belongs to. - Amber CSV import:
handleUsageCsvFile→import.js→setUsageDataMany. - NEM12:
handleNem12File→estimate.jsparses the file and rebuilds Amber's interval prices fromdata/pricesandnetworks.js. The result becomes a local site whose days are written to the cache.
Compare (app.js: fetchAndCompare; app-results.js: displayResults, renderAllPlansTable, renderMonthly; app-plan-form.js: the plan form)
- Read the cache for the dates, fetch what's missing (
api.js) and write final days back (loadReadingsinapp.js, shared with the bill check). processUsageData→ channel totals;applyAmberFeedInSettlement(Ausgrid EA029 export settlement, one calendar month at a time viaAmber.monthsInRange; Bill? settles the bill's own period withbillPeriod).calculateOtherSupplierCosts(the chosen plan),calculateDemandTariff/calculateOtherDemandTariff(monthly peaks),precalculateDailySummaries(daily rows and calendar).- Render the answer, every plan for the network (
computePlanTotalsper template), then month by month (monthly.js, after the main results).
Uses the same channel totals and daily summaries. Charts use the interval's local time and real interval length.
Reads the bill (pdf.js is loaded on demand), loads that period's meter data (from the cache or API), and compareBill checks each line. The load is the bill check's own (ensureBillLoaded → loadReadings → state.billData): the dates bar and the Compare results are left alone, and results already on show are used when they cover the bill. The last result is kept in localStorage amberBillCheck.
For each free-window plan, re-times each day's real readings. Grid use moves into the free window (dearest first) within a daily shift budget, the window's cap and an import limit. Battery exports move into the best feed-in window when a battery is present. The result is priced with the same cost functions as Compare.
cache ──planYear──▶ year ──(existing battery?)──plannerUnwindBattery──▶ year without it
│
solar.js: town weather → solarSlotsPerKw → clipToInverter (panels/1.33)
plannerExtraLoads: EV, heat-pump hot water, gas to electric
▼
plannerSearch(ctx) ── plannerPrepare (rate cards, prices, flows cache)
for each solar size × plan: plannerFlows (import/export after solar, export limit)
for battery sizes on the best few plans: simulateBattery
▼ rows { solarKw, batteryKwh, plan, bill, … }
planner-ui analyse(): yardstick, options, plannerFinance (NPV, IRR, payback), plannerKeenLevel
Key pieces:
- Rate cards (
planRateCard): each plan's buy, sell and controlled-load price per slot, plus free windows, feed-in windows and caps, and demand.planCardCostandamberCardCostprice any import/export arrays for the year or a month;plannerMonthsgives month ranges. - Flows (
plannerFlows): adds new solar and extra loads to the readings, then applies the export limit. The limit ismax(limit, what the meter already sent), andexpRawkeeps what would have gone out before the limit. - Negative feed-in: by default (
switchOffNegative), exports at a negative sell price are dropped (exportsFor, orcurtailNegativein the battery run). The UI says this needs a curtailable inverter. - Battery (
simulateBattery): one day at a time with that day's prices known (perfect daily foresight). It searches a charge threshold and a discharge threshold per day. It charges from solar over the export cap first, then from cheap or free grid; it discharges into dear use and high feed-in. Charge and discharge share the power limit, and a small wear cost per kWh stops pointless cycling.limitedcounts what's thrown away at the export limit. - Existing battery:
plannerDetectExistingguesses panels and battery from the readings.plannerUnwindBatteryremoves the battery's effect (grid sales are seen directly; what it covered at home is estimated), and the search re-runs it on every plan with any new battery.plannerExistingCheckcompares the planner's version with the real readings. - Search (
plannerSearch): every solar size on every plan without a battery. A probe battery ranks plans, then the full battery search runs on the bestkeepPlans(6) plus any that must be included.allowPlanslimits the plans considered to those ticked. - Money (
plannerCapex,plannerFinance): the up-front cost after rebates (the battery rebate followsAmber.BATTERY_REBATE_TIERS: full to 14 kWh, 60% to 28 kWh, 15% to 50 kWh). Then year by year: savings grow with price rises and shrink with panel and battery wear, the battery and inverter are replaced, and a battery replaced inside the period is credited with its remaining life. The results are NPV, IRR, payback "ahead for good" (and the first time it reaches $0), andplannerWaitAYear. - Ratings (
plannerKeenLevel): the value per $1 up front (≥1.75 Strong, ≥1.4 Good, ≥1.15 Modest). Strong and Good also need the result to hold with prices 20% lower and with 10am–3pm feed-in halved (dayFeedInScale), and payback within 8 years. - UI (
planner-ui.js):search()gathers inputs,analyse()builds options against the yardstick, and therender*functions build each card as HTML strings (escaped withesc).
| Where | Key | What |
|---|---|---|
| IndexedDB | AmberDataCacheDB / dailyUsage |
Meter readings per site and day |
| localStorage | amberApiKey (or sessionStorage) |
Obfuscated API key |
| localStorage | selectedSiteId, startDate, endDate, activeTab |
Where the user was |
| localStorage | selectedState, selectedPlan, rateType, planName, flatRate, flatFeedInRate, flatControlledLoad, dailyConnectionRate, touConfig, feedInWindows, enableFeedInTou, demandSettings, ratesDetailsOpen |
The Compare plan form |
| localStorage | selectedPlanSig |
Fingerprint (Amber.templateSignature) of the built-in plan the form was filled from. If it no longer matches on load, the plan's prices have changed and the form is filled again |
| localStorage | customSupplierPlans |
Saved custom plans |
| localStorage | Amber charge field IDs | Amber's fixed charges (this year and last) |
| localStorage | localSites |
Imported NEM12 households |
| localStorage | batteryShiftSettings |
Free? tab settings |
| localStorage | amberBillCheck |
The last bill check |
| localStorage | plannerSettings |
Every Solar+? input and the company ticks |
Saved JSON is read through storedJson (app-core.js) and storedCustomPlans (app-plan-form.js): a value that is missing, damaged or the wrong shape falls back to the default instead of stopping init().
- AEMO prices:
scripts/update-prices.jsdownloadsPRICE_AND_DEMAND_<YYYYMM>_<REGION>.csvfrom AEMO intodata/prices/<REGION>/<YYYY-MM>.jsonas{ region, month, interval: 5, rrp: [...] }($/MWh, interval endingmonth-01 00:00 + (i+1)×5 minNEM time)..github/workflows/update-prices.ymlruns it daily at 1:17 am AEST and commits changes. - PVGIS weather:
scripts/update-solar.jsdownloads a typical meteorological year (hourly GHI, DNI, DHI and temperature) and PVGIS's own 1 kW yield for each town inAmber.SOLAR_LOCATIONS. It's run by hand; the files don't change. - Plan prices: maintained by hand in
templates.js(withasAtdates) andnetworks.js(Amber's FY2026-27 network tariffs).Amber.TEMPLATES_AS_ATis shown in the UI.
- The CSP (
index.html<meta http-equiv="Content-Security-Policy">) isdefault-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://api.amber.com.au; img-src 'self' data:; …. This means no inline scripts, no inline<style>and nostyle="…"attributes (settingelement.style.xfrom JS is fine), and no third-party hosts. - All data stays in the browser. The API key is obfuscated (XOR with a fixed key, then base64), not encrypted.
- Text is formatted in one place each: dollars with
Amber.money(costs.js: "$1,234.56", a proper minus sign, whole dollars on Solar+?), dates withAmber.formatDate(time.js: "31 Oct 2026"), plan rates withAmber.formatCentsPerKwh("27.11 c/kWh"), and percentages with no space ("20%"). - Dynamic HTML is built with template strings. Anything from data or user input goes through
Amber.escapeHTML(aliasedescinplanner-ui.js), which escapes& < > " ', so it is safe inside an element and inside a quoted attribute.
npm run build:cssrebuildscss/app.css. Tailwind scansindex.htmlandjs/**/*.jsfor class names, andtailwind.config.jssafelists classes that are only built dynamically. Commitcss/app.cssafter changing classes.- Lint:
npm run lintruns ESLint 10 witheslint.config.js(a flat config). The files injs/are checked as classic browser scripts, and the unit checks, scripts and specs as CommonJS. On top of ESLint's recommended set it reports unused variables, shadowed names,var,==and aletthat never changes, and holds the house style (4-space indent, single quotes, semicolons, from@stylistic/eslint-plugin). One rule is the project's own:local/no-extra-arguments(eslint-rules/no-extra-arguments.js) reports a call that passes more arguments than the function takes, for functions andAmber.*functions defined in the same file. Rules are tuned to the code rather than the code restyled: the tests keep their own alignment of long asserts, andpreserve-caught-erroris off because network errors are replaced on purpose by plain messages. - Unit checks:
tests/unit-*.js, run with plainnode(npm run test:unit). Each printsok:/FAIL:lines and exits non-zero on failure.unit-syntax.jsruns first: it parses everyjs/*.js(no other unit script loads the DOM files such ashelp.jsandapp.js), checks thatindex.htmlloads each of them once, withapp.jslast, and checks the calls between the app files throughAmber.app.unit-planner.jsholds the parity checks: the planner's cost equals Compare's, battery energy balances, finance and ratings. - End-to-end:
tests/*.spec.jswith Playwright (Chromium) againstscripts/static-server.json port 4173 (playwright.config.js,reuseExistingServer: true). Tests mock Amber's API withpage.route('**/api.amber.com.au/**', …)and build NEM12 files in code. About 69 tests take about a minute with two workers. - CI:
.github/workflows/test.ymlrunsnpm ci,npm run lint, installs Chromium and runsnpm test -- --workers=2on every pull request and on pushes tomain. Failed runs uploadtest-results/.
Each change ships as a version:
- Bump the version in three places:
index.html(the<title>, the "Version X." line in the header, and every?v=query string),js/constants.js(Amber.APP_VERSION), andtests/unit-time.js(the version assert). npm run build:cssif classes changed; update the README and these docs if behaviour changed.- Run
npm run lintandnpm test, open a pull request, and merge once the Tests check passes. GitHub Pages servesmain.