Front end of CasaFlux, my personal EMS (Energy Management System) and home-control app.
  • JavaScript 90.2%
  • CSS 8.5%
  • Python 1%
  • HTML 0.2%
  • Shell 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-02 16:58:29 +02:00
.claude TASKS.md sanitation 2026-08-29 07:41:24 +02:00
src Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00
test Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00
tools Webworker, certificate and dev-server (http 1.0 -> 1.1) fixes. 2026-08-28 17:21:15 +02:00
.gitignore https support so WebWorker will work from iPhone. 2026-08-28 16:09:48 +02:00
API-CONTRACT.md Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00
apple-touch-icon.png Added app icon 2026-08-26 06:54:09 +02:00
CLAUDE.md Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00
CONFORMANCE-HISTORY.md Initial setup of clickable subjects 2026-08-29 19:44:45 +02:00
CONFORMANCE.md Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00
deploy.sh Add deploy wrapper and deploy documentation 2026-09-02 14:07:54 +02:00
favicon.svg Added favicon 2026-08-13 09:30:29 +02:00
icon-192.png Added app icon 2026-08-26 06:54:09 +02:00
icon-512.png Added app icon 2026-08-26 06:54:09 +02:00
icon-source.svg Added app icon 2026-08-26 06:54:09 +02:00
index.html Webworker, certificate and dev-server (http 1.0 -> 1.1) fixes. 2026-08-28 17:21:15 +02:00
manifest.json Added app icon 2026-08-26 06:54:09 +02:00
package.json Initial development: general layout and labelling 2026-08-07 07:49:11 +02:00
README.md Settings page now also contains the power page histogram clip value and the debug panel. 2026-08-28 21:36:34 +02:00
serve.py Webworker, certificate and dev-server (http 1.0 -> 1.1) fixes. 2026-08-28 17:21:15 +02:00
SERVER-CONFORMANCE.md Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00
SPEC.md Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00
sw.js Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00
TASKS.md Initial EV and Solar subject panel 2026-09-02 16:58:29 +02:00

CasaFlux frontend

Client for CasaFlux, a targeted application for following and controlling energy-related appliances in the house: power, lights, A/C, gas and water.

What it covers and why is in SPEC.md. This file is only about working with this repository.

Documents

File Job
SPEC.md What the client does, and why each choice was made
CONFORMANCE.md Client rules, numbered CF-, derived from SPEC.md
API-CONTRACT.md What goes over the wire — shared with the backend
SERVER-CONFORMANCE.md What the backend must guarantee, numbered CFS-
CLAUDE.md How to work on this repository

SPEC.md and the conformance documents are implementation-agnostic on purpose: they should hold for a rewrite in another framework, or a native app. Anything that is true only of this web client belongs here.

Stack

Vanilla JavaScript, no build step. Libraries are added only where they earn their place, mainly for graphs and graphics. JSDoc annotations with tsc --checkJs --noEmit where type checking is useful.

The one generated thing is the home-screen icons. A host will not accept an SVG there, so icon-source.svg is rasterized by tools/render-icons.sh into the three PNGs, which are committed. It is not part of running or testing the app — run it after editing the icon and commit what it writes. It uses headless Chrome as the rasterizer rather than adding a dependency, on the grounds that a build tool installed to produce three files that change once a year is a poor trade.

Colour themes

Every colour lives in src/ui/theme.css as a custom property named for its role. No colour literal belongs anywhere else — not in another stylesheet and not in JavaScript. Adding a theme is a block of variables there plus a name in THEMES in src/lib/theme.js; no drawing rule changes.

Which theme applies is resolved in src/lib/theme.js and published as data-theme on the root element. When the preference is "follow the system" that attribute is deliberately absent, so the prefers-color-scheme media query decides and the page follows the OS live; an explicit choice sets the attribute, which the media query is guarded against. First paint is therefore correct before any script runs.

The theme is chosen on the Settings page, reachable from the section bar. It used to live in the debug panel and moved here when the page was built, which is what CF-DEBUG-012 asks of any control parked there. The histogram clip has since followed it, and the panel now carries no such control.

Settings live in one place rather than per subject area, including a setting that belongs to one area (the clip belongs to the History view). The reason is room: a per-area settings surface needs a way in from each area, and the top of a subject page is the most contested space in the interface.

Languages

The interface speaks English and Dutch. Every word it shows lives in src/i18n/, one module per language, and nothing outside that directory holds a user-facing string. Adding a language is a module there plus a name in LANGUAGES in src/lib/language.js; no other code changes.

A catalogue exports three things: strings for fixed phrases, format for phrases that are composed, and the month and weekday names. The format half is why this is not a flat table of keys. Several names are built rather than looked up, and the rules for building them are grammar:

  • Dutch does not pluralise "uur" after a numeral — "24 uur", never "24 uren" — while "dag" and "minuut" do, so no suffix rule covers the ladder.
  • "This/Last/Next" inflects for gender: "Deze week" but "Dit jaar".
  • English writes "Last week" by dropping the count from "1 week".

A table would force every language into English's shape. Named functions let each compose its own, and describeRange in src/lib/timeRange.js — which is where most of this is used — decides only which phrase applies.

Read a string with t('key') and a composed one with f('name')(...), both from src/i18n/index.js. A key missing from a translation falls back to English and warns once to the console. test/i18n.test.js fails if the two catalogues stop matching key for key, which is what catches a forgotten translation — the fallback means it would otherwise never be noticed.

Numbers do not vary by language: the decimal separator is always a full stop and there is no thousands separator at all. Dates translate their month and weekday names only; day-month order and the 24-hour clock are the same in both. Units (W, kW, Wh, kWh, ct, %) are symbols and are never translated.

The language is chosen on the Settings page. Unlike the theme it is a user preference — it is meant to follow the person across devices rather than belong to one — so it is held by src/lib/userPreferences.js rather than by windowStore.js. That service is one interface over two implementations: the local one ships today, and the HTTP one replaces it when the backend has somewhere to put preferences, with no caller changing.

Changing the language reloads the page. It is the only setting that does, and the reason is in SPEC.md §Locale: text is built into the DOM when a page is constructed, so applying it live would mean a re-render path on every part of the interface, carried permanently for a setting chosen about once.

Vocabulary

SPEC.md §Vocabulary fixes four terms and the code follows them:

Term Is In the code
page what a bar button brings to front src/lib/pages.js, PageBar
view one arrangement of a page's content viewMode.js, ModeToggle
section a titled block within a page <section> on the Settings page
mode a filter on what a view shows valueMode.js, ValueModeToggle

ModeToggle is a view switcher despite its name — it predates the vocabulary and is worth renaming next time that file is opened.

Layout and navigation

src/ui/appShell.js owns the page bar and the pages, and is the only thing that knows which page is showing. Pages are built the first time they are shown and then kept and hidden, so a page returned to still has the view, range and scroll position it was left in. Every built page keeps receiving payloads while hidden; only its animations stop.

The page list and the rule for where the bar sits are in src/lib/pages.js, which has no DOM access and is tested under node --test. Three placements: bottom edge on a narrow window, left edge on a short one, top edge otherwise, with width breaking the tie when a window is both. The first two fill their edge; the top bar takes only the width its buttons need.

The bar publishes its own measured thickness as --page-bar-size on the root element, and stamps data-bar-placement beside it. Both are read back by src/lib/barInset.js. That indirection exists because the stylesheet and the Power page's pinned elements both need the figure and only one of them can be written in CSS — the lower time axis positions itself from window.innerHeight in script. Measured rather than stated, because the bar's height is padding plus an icon plus a word in the platform's own font, and a figure written twice is one that will disagree.

At the top edge the pinned element is the Power page's head — the range controls, the range description and the upper time axis, fixed as one block — and it clears the bar from CSS. The axis then hangs off the head's own measured bottom rather than off the bar again, so the bar's extent is counted once.

Placing the flow picture by hand

Settings → Debug panel (at the bottom, under Developer) → Drag layout, then go to the Power page. Drag the subject cards or the hub around the flow view, then return to Settings — Copy layouts puts every placed position on the clipboard (console if the clipboard is refused) as a block of fractions per screen-size bucket, ready to read into SUBJECTS in src/lib/busGeometry.js.

The switch and the picture are on different pages since the panel stopped floating, so the tool stays on while you walk between them. That is the one control this arrangement made more awkward, and it was a deliberate trade for an interface with nothing permanently stuck to a corner of it.

Positions are bucketed by viewport size (src/lib/layoutOverride.js), so layouts dragged at several sizes can be compared side by side — which is the point: the computed layout needs one rule that works everywhere, and it is easier to find that from a handful of hand-made answers than from first principles. A card dragged past the hub flips the side it is approached from, so its sockets and its flow follow it.

Switching the tool off restores the computed layout exactly. Nothing it stores reaches the normal client.

Running

python3 serve.py 8420

serve.py sends no-store, so a refresh always picks up what is on disk — plain http.server caches ES modules hard enough to survive a reload.

Leave it running. With no server the service worker falls back to its cached copy — which is the offline shell working as designed — and serves the application as of its last successful load. That looks exactly like a feature never having been built, and has twice cost real time to diagnose.

Expect one stale reload after an edit. The worker answers from its cached copy and fetches the fresh file behind that answer (CF-OFF-016), so a change appears on the second reload, not the first. This is the trade made for startup speed and is not a fault. If something is still stale after two or three reloads, that is worth investigating; the first question is always whether the server is up, and the check is in TASKS.md T23.

Starting fast

An installed client starts from its own cached copy and only then looks for a newer one, which is what lets it open like an application instead of a page. The reason is round trips rather than bytes: the client is 84 ES modules with no build step, and a module cannot be requested until its importer has arrived and been parsed, so a network-first start cost 85 requests in 8 dependent waves. Bytes are cheap; a chain of round trips on a phone is not.

The cost is that a launch shows the application as it was. The client closes that gap itself — a newer version found in the first few seconds after launch restarts without asking, since nothing is yet in progress to lose (CF-OFF-014), and once per launch only, because a restart that does not resolve the difference would otherwise loop (CF-OFF-015).

Bundling would collapse the 8 waves into 1 and was considered and rejected: it would buy speed at the cost of the no-build-step property in §Stack. Worth reopening only with measurements from a real device.

Bump the version when you ship

CLIENT_VERSION in src/lib/clientVersion.js is what an installed client compares against the version the backend reports. If it is not bumped, an installed client has no way to know it is out of date, and the automatic restart above never fires — a phone will go on running the cached copy until something else displaces it.

An edit still reaches the device without a bump, but only on the second launch: the first is answered from the cache while the new files are fetched behind it. Bumping is what turns that into a restart the user does not have to know about.

Minor or patch for anything compatible; major only for a change that makes an older client unable to work with the backend, since a major difference forces a restart the user cannot decline (CF-OFF-011).

Testing on a phone

A service worker only registers in a secure context, and localhost is the sole exemption. So opening the plain-http server from a phone at 192.168.1.x gives an app with no worker at all: the registration in index.html fails, is swallowed on purpose, and neither the offline start nor the cache-first start above happens. Nothing on screen says so, which is what makes it worth stating here.

Testing either of those on a real device therefore needs TLS:

python3 tools/make-cert.py     # once
python3 serve.py 8420 --https

This writes two certificates. The root, casaflux-dev.crt, is the one installed on the phone; the leaf is what the server presents. They cover localhost and this machine's current LAN address, so re-run it when that address changes — and give the phone the new root, since the old one cannot sign the new leaf. .devcert/ is git-ignored: it holds private keys and is regenerated in seconds.

The server offers the root at /casaflux-dev.crt, over http as well as https — the phone cannot trust the https connection until it has that file, so requiring https to fetch it would be a circle with no way in. Only the public certificate is served this way; the keys beside it are refused.

The service worker excludes that path, because otherwise it answers the request itself and a typed url — a navigate request — falls back to index.html, so asking for the certificate opens the app instead. If a worker is already installed on the phone from an earlier attempt, that fix cannot deliver itself: it only takes effect after a load, and the load is what is failing. Use a second port, since a worker's scope includes the port and a different port is an origin with no worker on it:

python3 serve.py 8421          # second terminal, plain http

then fetch http://<lan-address>:8421/casaflux-dev.crt on the phone.

Type the http:// prefix explicitly. Safari upgrades a bare typed address to https, and a plain-http port answers nothing over TLS: the phone reports "no secure connection possible", which reads as the server being down rather than as the wrong scheme.

Why two and not one. A single self-signed certificate cannot be both the trusted root and the certificate served on the connection: only a CA may be installed as a root, and Apple's leaf rules — extendedKeyUsage=serverAuth, keyEncipherment, CA:FALSE — are exactly what a CA is not. The first version of this tooling used one certificate for both and produced a symptom worth recognising, because it mimics the trust step failing: the profile installs, the switch appears under Certificate Trust Settings and turns on, and Safari still says "not secure". The trust had taken; the same certificate then failed leaf validation one step later. The tell is that an untrusted root gives Safari's full interstitial, while this gives no warning at all — just a quiet "not secure" in the bar.

The same insecure context is why the home-screen icon is missing when this is wrong: iOS only reads the manifest and apple-touch-icon for a page in a valid secure context, so it falls back to a screenshot. A generic icon is a useful signal that the certificate, not the icon, is the problem.

The root is name-constrained. iOS will only trust a root, and an unconstrained trusted root can vouch for any host at all — so a forged certificate for any site would be accepted by the phone, signed by a key sitting on a laptop. The constraint limits it to localhost and the one LAN address, so even fully trusted it is worthless for anything else.

On iOS, accepting Safari's warning is not enough. Tapping through loads the page but leaves the context insecure, so the worker still refuses to register and everything looks like it worked except the thing being tested. The root has to be installed as a profile and switched on under Settings → General → About → Certificate Trust Settings. tools/make-cert.py prints the full sequence and the one-liner that checks whether it took. If a root was installed before, remove the old profile first — a stale root fails the same quiet way.

Tests

npm test

Read the counts carefully. The runner here is Node 18's, which reports one line per file, not per test: a file of twenty tests that all pass prints ok 1 and # pass 1. Individual tests are named in the output only when they fail. So # pass 12 means twelve files, and a file whose tests never ran at all looks exactly like one whose tests all passed.

The consequence for the "watch it fail" habit in CLAUDE.md §Testing: the failure is the only output that names the test, which makes watching it fail the only proof a test runs at all. A test asserted to pass on the strength of # pass 1 has not been shown to exist.

Prefer injected clocks and schedulers over real timers in tests. A leaked setTimeout — a retry loop that keeps rescheduling after its test ends — does not fail the file, it cancels it: # cancelled 1, with no test named and nothing to point at. test/connection.test.js drives time by hand for this reason.

Backend

The client runs against a mock data service until the CasaFlux backend is ready. The mock is permanent — it stays after the backend arrives, for testing and for UI work that should not depend on live data. It also carries the scenarios that make the conformance rules checkable.

There are two things to talk to: a websocket carrying current values, and an HTTP endpoint serving timeline spans. Both sit behind one transport layer whose first implementation is the mock. When the backend exists, that layer gains a real implementation and the mock moves behind a flag rather than being deleted — debugging against known data, with no server running, stays possible.

The layer is where the mock/real choice lives so that nothing above it knows which it is talking to. A component that reached for the mock directly would have to be changed twice: once to use the real service and once more the next time somebody wanted to debug without one.