diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..13dd7c3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# Working on Amber Compare & Export + +A browser-only tool for Australian electricity bills (Amber Electric vs other plans, bill checks, a solar and battery planner). Read these first: + +- [README.md](README.md): what the app does, for users +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): modules, data model, each tab's flow, the planner engine, storage, releases +- [docs/FRONTEND.md](docs/FRONTEND.md): the UI contract (IDs, data attributes, help keys, CSP) and known UI issues +- [docs/REVIEW.md](docs/REVIEW.md): invariants, risk areas, technical debt, test map + +## Commands + +```bash +npm install +npm run build:css # after changing any Tailwind class in index.html or js/; commit css/app.css +npm run test:unit # Node unit checks (fast) +npm test # unit checks + Playwright (starts scripts/static-server.js on :4173) +npx playwright test tests/planner.spec.js --workers=2 # one spec +npm run serve # http://127.0.0.1:4173 +``` + +If Playwright can't find its browser, point it at an installed Chromium with `launchOptions.executablePath` in a local config, not by editing `playwright.config.js`. Don't leave your own server running on port 4173 during a test run: Playwright reuses it. + +## Rules of the codebase + +- **No build step for JS, no framework, no ES modules.** Each `js/*.js` is an IIFE adding to `window.Amber`; the load order in `index.html` matters (`app.js` is last). New files need a ` - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/api.js b/js/api.js index a97087d..69ee7de 100644 --- a/js/api.js +++ b/js/api.js @@ -1,6 +1,13 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * Amber API client (https://api.amber.com.au/v1): the site list, and usage for a date range + * fetched in chunks of Amber.FETCH_CHUNK_DAYS, a few at a time (mapPool). Every request has a + * timeout; 429s, server errors and network failures are retried after Amber.RETRY_DELAYS_MS. + * The caller (app.js fetchAndCompare) caches what comes back (storage.js). + */ + Amber.sleep = function (ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }; diff --git a/js/app.js b/js/app.js index 3148513..aa41b0c 100644 --- a/js/app.js +++ b/js/app.js @@ -1,6 +1,16 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * The page controller for everything except the Solar+? tab (planner-ui.js): start-up (init), + * tabs, the Start tab (API key, sites, Amber's fixed charges, Amber CSV and NEM12 imports), the + * dates bar, Compare (the plan form, fetchAndCompare, the answer, every plan, month by month), + * Usage (charts, calendar, More Stats), Bill?, Free? (the re-scheduling estimate), downloads and + * the settings menu. Pricing lives in costs.js / monthly.js; this file reads inputs, calls it + * and renders the results. Module state is in `state` (exposed as Amber._appState for tests); + * Amber.appApi is what planner-ui.js may use. See docs/ARCHITECTURE.md. + */ + const state = { templates: Amber.cloneTemplates(), originalTemplates: Amber.cloneTemplates(), diff --git a/js/bill.js b/js/bill.js index e8da3d1..b28bb93 100644 --- a/js/bill.js +++ b/js/bill.js @@ -1,6 +1,14 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * Amber bills: the text of a PDF (pdf.js, loaded on demand) or a text file is read into the bill + * period, Amber's fixed rates (connection, subscription, demand) and the line items + * (parseAmberBillText, parseBillCheck). compareBill then checks each line against the meter + * data for the same period. Used by the Bill? tab, and on Start to fill Amber's fixed charges. + * Layout-dependent: if a bill can't be read, the caller shows the text that was read. + */ + function roundCents(n) { if (!Number.isFinite(n)) return null; return Math.round(n * 1000) / 1000; diff --git a/js/calendar.js b/js/calendar.js index a5ab0fe..e7c74fc 100644 --- a/js/calendar.js +++ b/js/calendar.js @@ -1,6 +1,13 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * The Usage tab's calendar: a grid per month of the days in the selected period, with the + * day's cost on Amber and on the plan compared with in a tooltip (and Amber's demand peaks); + * clicking a day opens it on the daily graph. Days without readings say so rather than + * showing zero. + */ + Amber.hideTooltip = function () { const tooltip = document.getElementById('day-tooltip'); if (tooltip) tooltip.remove(); diff --git a/js/charts.js b/js/charts.js index a60321a..e07b2ce 100644 --- a/js/charts.js +++ b/js/charts.js @@ -1,6 +1,14 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * The Usage tab's Chart.js charts: the 24-hour average day (displayAverageGraph) and one day + * up close (displayDailyGraph), per channel (general, controlled load, feed-in) with Amber's + * price and the compared plan's c/kWh in the tooltip, on local time and the real interval + * length, with zoom and pan (chartjs-plugin-zoom). registerChartPlugins adds the cursor + * tooltip and the hover highlight once. + */ + Amber.registerChartPlugins = function () { if (!root.Chart || Amber._chartPluginsRegistered) return; root.Chart.Tooltip.positioners.cursor = function (items, eventPosition) { diff --git a/js/constants.js b/js/constants.js index ac244ae..bb1f1e0 100644 --- a/js/constants.js +++ b/js/constants.js @@ -1,6 +1,14 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * App-wide settings and reference data: Amber API limits, the app version (APP_VERSION, also + * in index.html and tests/unit-time.js), Amber's default fixed charges for this financial year + * and last (split at AMBER_RATES_CHANGE_DATE), state time zones, network names and matching + * plans to a site's network, and the company behind each plan (planCompany). Update each + * 1 July with templates.js and networks.js. + */ + Amber.API_URL = 'https://api.amber.com.au/v1'; Amber.FETCH_CHUNK_DAYS = 7; Amber.FETCH_CONCURRENCY = 3; @@ -12,7 +20,7 @@ // Compare "This month" when the page opens with a saved API key (a test can preset // window.Amber.AUTO_COMPARE = false to start idle). Amber.AUTO_COMPARE = Amber.AUTO_COMPARE !== false; - Amber.APP_VERSION = '1.60'; + Amber.APP_VERSION = '1.61'; Amber.TEMPLATES_AS_AT = '2026-07-01'; // All editable rates (plans, custom, Amber fixed charges) are GST-inclusive, // matching Energy Made Easy, DMO/VDO, retailer fact sheets and Amber perKwh. diff --git a/js/costs.js b/js/costs.js index f42d114..b4fbe2d 100644 --- a/js/costs.js +++ b/js/costs.js @@ -1,6 +1,20 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * Pricing a period of meter data. processUsageData turns API-shaped usage items into + * per-channel totals (with each interval kept for re-pricing); from those: + * - Amber: interval prices, the Ausgrid EA029 export settlement (applyAmberFeedInSettlement), + * demand per calendar month (calculateDemandTariff), daily connection and subscription + * for the year each day falls in (amberRatesOn); + * - another plan (calculateOtherSupplierCosts, computePlanTotals): flat or time-of-use rates, + * free windows with daily caps, controlled load, flat, windowed and capped feed-in (caps + * shared across channels: householdCappedSplits), demand (calculateOtherDemandTariff); + * - daily rows for the calendar and CSV (precalculateDailySummaries). + * Compare, month by month (monthly.js), Bill Check and Free? all price through here; the + * planner's half-hourly costing is checked against computePlanTotals (tests/unit-planner.js). + */ + Amber.absKwh = function (value) { const n = parseFloat(value); if (!Number.isFinite(n)) return 0; diff --git a/js/csv.js b/js/csv.js index a119585..98e1d78 100644 --- a/js/csv.js +++ b/js/csv.js @@ -1,6 +1,11 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * CSV downloads: every meter reading (a versioned file that the Amber CSV import on Start can + * restore as a backup), one row per day, or the comparison table as shown. + */ + Amber.downloadCSV = function (csvContent, fileName) { const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); diff --git a/js/monthly.js b/js/monthly.js index b7c7f16..d7894b1 100644 --- a/js/monthly.js +++ b/js/monthly.js @@ -1,6 +1,12 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * Month by month on Compare: Amber and the compared plan for each calendar month (and any + * period), worked out the same way as the main comparison (feed-in settlement, monthly demand, + * daily charges for every day). + */ + function daysInMonth(monthKey) { const y = parseInt(monthKey.substring(0, 4), 10); const m = parseInt(monthKey.substring(5, 7), 10); diff --git a/js/stats.js b/js/stats.js index d18da7c..f8c6a71 100644 --- a/js/stats.js +++ b/js/stats.js @@ -1,6 +1,12 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * More Stats on the Usage tab: average import and feed-in prices, power bought and sent out + * during price spikes (above STATS_DEFAULTS.spikeCents) and at negative prices, the evening + * share of imports, and days with no grid use. + */ + Amber.STATS_DEFAULTS = { spikeCents: 50 }; diff --git a/js/storage.js b/js/storage.js index 02b2252..2af8a66 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1,6 +1,14 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * Browser storage. IndexedDB AmberDataCacheDB / dailyUsage holds the meter readings, one record + * per site and NEM date (id "_"). Only final whole days are cached (with + * old empty days remembered), and estimated readings stay eligible for a refetch + * (cachedDayUsable). Also the API key (obfuscated, not encrypted; localStorage, or + * sessionStorage when not remembered) and escapeHTML for everything put into innerHTML. + */ + const DB_NAME = 'AmberDataCacheDB'; const STORE_NAME = 'dailyUsage'; const DB_VERSION = 1; diff --git a/js/templates.js b/js/templates.js index 6a4ac00..7534962 100644 --- a/js/templates.js +++ b/js/templates.js @@ -1,6 +1,13 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * Built-in plans to compare with, by state: default offers (DMO, VDO), market offers, free-window + * plans and the Solar Sharer Offer, each with the date its prices are as at. The shape of a plan + * is in docs/ARCHITECTURE.md (Plan configs). Prices are typed in by hand from published fact + * sheets: update each 1 July (default offers) and when retailers change their offers. + */ + const ALL_DAYS = [1, 2, 3, 4, 5, 6, 0]; const WEEKDAYS = [1, 2, 3, 4, 5]; const AS_AT = '2026-07-01'; diff --git a/js/time.js b/js/time.js index 2ce6403..86e6ec2 100644 --- a/js/time.js +++ b/js/time.js @@ -1,6 +1,13 @@ (function (root) { const Amber = root.Amber = root.Amber || {}; + /* + * Dates and times. Amber's nemTime is an interval's end in NEM time (UTC+10, no daylight + * saving); plans' windows are local wall-clock time (or NEM time with clock: 'nem'). Helpers + * here convert between them (getClockParts, localWallToNemIso), match intervals to time-of-use + * windows (timeInWindow, windowMatches), and do day arithmetic on YYYY-MM-DD strings. + */ + function pad(n) { return String(n).padStart(2, '0'); } diff --git a/tests/unit-time.js b/tests/unit-time.js index f55b1f8..0fba87a 100644 --- a/tests/unit-time.js +++ b/tests/unit-time.js @@ -78,7 +78,7 @@ const clockA = Amber.clockPartsForItem(clockItem, vicLocal, 'VIC'); const clockB = Amber.clockPartsForItem(clockItem, vicLocal, 'VIC'); assert(clockA === clockB, 'clock parts are cached on the usage item'); assert(clockA.hours === 17, 'cached Melbourne DST hour is 17'); -assert(Amber.APP_VERSION === '1.60', 'app version is 1.60'); +assert(Amber.APP_VERSION === '1.61', 'app version is 1.61'); assert(Amber.DEFAULT_AMBER_CONNECTION_CENTS === 125.367 && Amber.PREVIOUS_AMBER_CONNECTION_CENTS === 116.787, 'Amber daily connection defaults (2026-27 and 2025-26)'); assert(Amber.DEFAULT_AMBER_SUBSCRIPTION_CENTS === 82.181, 'Amber subscription default'); assert(Amber.DEFAULT_AMBER_DEMAND_CENTS === 43.431 && Amber.PREVIOUS_AMBER_DEMAND_CENTS === 42.345, 'Amber demand defaults (2026-27 and 2025-26)');