- C# 55.5%
- JavaScript 37.8%
- CSS 3.9%
- Python 1.4%
- Shell 0.7%
- Other 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .devcontainer | ||
| .forgejo/workflows | ||
| css | ||
| deploy | ||
| docs | ||
| js | ||
| src | ||
| tests | ||
| tools | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc | ||
| compose.yaml | ||
| Directory.Build.props | ||
| Directory.Packages.props | ||
| Hourmeter.drawio | ||
| Hourmeter.slnx | ||
| index.html | ||
| README.md | ||
Hourmeter
The front end and backend for the process modelled in Hourmeter.drawio: a bike owner
registers, adds bikes, logs rides and services, and keeps receipts and photos. Bikes are
measured in engine hours, not distance — which is where the name comes from. The bike page
shows the meter, the reading the bike arrived with, how much of it the owner has ridden, and
how many hours have run since the last service.
The diagram calls a bike a "vehicle". The code keeps that word wherever it mirrors a task
name (Api.storeVehicleData, the AddVehicle page) so the trace still lines up; everything
the owner reads says bike. Renaming the tasks in the .drawio and re-running the generator
would let the code follow suit.
The client talks to the real API at /api/v1. Nothing is kept in the browser: the session is
an HttpOnly cookie, and every bike, service and document lives in PostgreSQL and on the
file share behind the API.
Run it
docker compose up --build # http://localhost:8777
That is the whole system: nginx serving the client and proxying /api, the API, PostgreSQL,
a one-shot migration that runs before the API starts, and Mailpit at
http://localhost:8025 catching the verification mail. Nothing else needs installing, and
the first build takes a few minutes while the .NET images come down.
There is no demo account. Register one — the link in the caught mail activates it.
Everything is kept in named volumes, so docker compose down keeps your data and
docker compose down -v throws it away. Front-end edits are baked into the web image:
docker compose up -d --build web picks them up. The defaults, including the database
password, are development defaults — copy .env.example to .env to change them.
Production is Kubernetes from the same two images, not compose. Push to main and
.forgejo/workflows/build.yml tests, builds and pushes both
images to Forgejo's container registry; Flux takes them from there. The manifests live with the rest of the
cluster, in lullen/home-server — see deploy/README.md.
Working on the backend
Compose rebuilds the API image on every change, which is slower than the dev loop deserves.
In the dev container (.devcontainer/: .NET 10, Python, PostgreSQL and Mailpit come up
ready), run the pieces directly instead:
# once, to create the schema
ConnectionStrings__Default="Host=localhost;Database=hourmeter;Username=hourmeter;Password=hourmeter" \
dotnet run --project src/Hourmeter.Host -- --migrate
# the API
ConnectionStrings__Default="Host=localhost;Database=hourmeter;Username=hourmeter;Password=hourmeter" \
ASPNETCORE_URLS=http://127.0.0.1:8080 Storage__Root=/data/documents \
dotnet run --project src/Hourmeter.Host
# the client, in another terminal
tools/serve.py # http://localhost:8777
tools/serve.py serves the static files and proxies /api to the API, which is what nginx
does in the compose stack
(deploy/docker/web/nginx.conf.template).
They have to look like one origin or the session cookie will not come back. A plain
python3 -m http.server will not work for the same reason.
The process panel
The Process button (top right) opens a live trace: the five BPMN pages are rendered as SVG straight from the diagram's own coordinates, the task you are currently performing is highlighted, tasks you have passed are green, and every call into the API is listed underneath. It is the quickest way to check that a screen matches the model.
Diagram → screens
| BPMN page | Lane | Task | Where it happens |
|---|---|---|---|
| RegisterUser | User | View login page | #/login |
| RegisterUser | User | View register user page → Request register user | #/register |
| RegisterUser | System | Register inactive user | POST /auth/register |
| RegisterUser | SMTP provider | Send verification email | the server's outbox → SMTP |
| RegisterUser | User | View verification sent page | #/verification-sent |
| RegisterUser | User | Request activate user | #/activate?token=… |
| RegisterUser | System | Activate user | POST /auth/verify |
| AddVehicle | Owner | Request owned vehicles | #/bikes |
| AddVehicle | System | Retrieve owned vehicles | GET /bikes |
| AddVehicle | Owner | Add vehicle → Fill in vehicle data → Upload receipts / photos | #/bikes/new (3 steps) |
| AddVehicle | System | Create new vehicle / Store vehicle data | POST /bikes — one call for both |
| AddVehicle | System | Store vehicle documents | POST /bikes/{id}/documents |
| EditVehicle | Owner | View vehicle | #/bikes/:id |
| EditVehicle | System | Retrieve vehicle data | GET /bikes/{id} |
| EditVehicle | Owner | Edit vehicle → Fill in vehicle data | #/bikes/:id/edit |
| EditVehicle | System | Store vehicle data | PATCH /bikes/{id} |
| LogRide | Owner | Add ride → Fill in ride data | #/bikes/:id/rides/new |
| LogRide | System | Store vehicle ride | POST /bikes/{id}/rides |
| LogRide | Owner | Correct ride | #/bikes/:id/rides/:rideId/edit |
| LogRide | System | Store corrected ride | PATCH / DELETE /rides/{id} |
| AddService | Owner | View vehicle | #/bikes/:id |
| AddService | System | Retrieve vehicle data | GET /bikes/{id} |
| AddService | Owner | Add service → Fill in service data → Upload receipts / photos | #/bikes/:id/services/new (3 steps) |
| AddService | System | Store vehicle service / …service data | POST /bikes/{id}/services — one call for both |
| AddService | System | Store vehicle service documents | POST /services/{id}/documents |
| AddService | Owner | Fill in service data (editing) | #/bikes/:id/services/:serviceId/edit |
| AddService | System | Store vehicle service data (editing) | PATCH / DELETE /services/{id} |
Editing a service has no diagram of its own yet; the edit screen traces onto AddService's
tasks. Adding an EditService page to Hourmeter.drawio, like EditVehicle, would give it one.
Two System-lane tasks have no call of their own. The diagram models create the record and
store its data separately, but the API creates and stores in one request, so nothing can be
left half-written. The wizard keeps all three visible steps, because the step sequence is the
process owners understand — see docs/design/07-frontend.md §3.
Tests
dotnet test Hourmeter.slnx # C#: managers, resource access, and the API over real PostgreSQL
node --test "tests/js/*.test.mjs" # the client, against a stubbed fetch
tools/e2e.sh # the client against a real API, database and SMTP hop
| Suite | What it covers |
|---|---|
tests/Hourmeter.Managers.Tests |
Manager sequences against substituted access services |
tests/Hourmeter.Access.Tests |
SQL and transactions, against a real PostgreSQL in Testcontainers |
tests/Hourmeter.Api.Tests |
The whole application over HTTP and real PostgreSQL — status codes, problem+json, cookies, ownership, uploads. This is what pins the wire contract js/api.js codes against |
tests/js/api.test.mjs |
What the client sends for each System-lane task, and how it reads each answer |
tests/js/views.test.mjs |
The seams: every form input is named after a real request field, every call exists |
tests/js/e2e.test.mjs |
The client driven against a live stack; skipped unless HOURMETER_API is set |
tools/e2e.sh starts PostgreSQL and Mailpit, migrates, runs the API and points the Node
tests at it. It is the only suite that exercises the outbox and the SMTP provider lane, so it
waits for mail to arrive rather than assuming it has — about ten seconds per registration.
The C# suites need Docker for Testcontainers. The Node suites need nothing but Node.
Layout
index.html app shell
css/app.css design tokens, light + dark
js/ui.js DOM and formatting helpers, engine hours, image downscaling
js/api.js the API client — the only file that knows HTTP
js/bpmn.js renders the diagrams as SVG, keeps the process trace
js/views.js the screens
js/app.js hash router and page chrome
js/process-model.js generated from Hourmeter.drawio — do not edit
tools/extract-process.py regenerates js/process-model.js
tools/serve.py serves the client and proxies /api, for development
tools/e2e.sh brings up the stack and runs the end-to-end tests
src/Hourmeter.Contracts every interface and DTO; nothing else is shared
src/Hourmeter.Managers.Service the four manager contracts — the sequences
src/Hourmeter.Access.Bike bikes, services, documents, the file share
MaintenanceIntervals/ one CSV of service intervals per bike, Make/Model-Year.csv,
and pre-ride.csv, the checks before every ride
src/Hourmeter.Access.Participant participants and verification tokens
src/Hourmeter.Utilities password hashing, tokens, the clock, the outbox
src/Hourmeter.Host endpoints, jobs, composition root
deploy/docker/web/ the front-end image
.forgejo/workflows/ build both images on push to main
To add a bike to the maintenance catalogue, copy MaintenanceIntervals/Yamaha/YZ125-2022.csv
to <Make>/<Model>-<Year>.csv and fill it in. It is embedded at build time and read on start;
a row without any interval, or with an unknown service_type, stops the API from starting
rather than showing an owner a wrong number.
The columns: manual_* is the owner's service manual's chart, race columns converted to hours
(every race ≈ 2.5 h) and its "every year" / "every 2 years" columns to months — left empty
where the manual only says "as required". normal_* is for riding for fun and limit_* is as
long as the job should ever wait. service_type is the system the part belongs to, whatever is
done to it: a brake check and a brake fluid change are both Brakes. Inspection is only for
whole-bike checks (fasteners, frame), and Other for systems the list has no name for (cooling,
exhaust, fuel, ignition, clutch). The catalogue tests hold every file to this: intervals widen
from manual to normal to limit, a part is never replaced sooner than it is checked, and a part
has one service type.
Checks done before every ride (tyre pressure, chain slack and lube, brakes, loose bolts) do not go
in a schedule — they would always be due. They are in MaintenanceIntervals/pre-ride.csv
(item,check), one list for every bike, shown as the pre-ride check on the bike page.
After editing Hourmeter.drawio, regenerate the model:
python3 tools/extract-process.py
Design documents
How the system is built — C# services following The Method, PostgreSQL, a mounted file
share, Docker — is written up in docs/technical-design.md,
with the detailed set in docs/design/. The BPMN model in
Hourmeter.drawio remains the source of truth for what the system does.
Still missing
- CSRF. The session cookie is
SameSite=Laxand the client is same-origin, which covers the common cases, but 07 §2 describes anX-CSRFheader the server does not yet issue or check. - Idempotency keys on
POST /bikesandPOST /bikes/{id}/services, andETag/If-Matchon bike updates (03 §1). - Paging.
GET /bikesanswers the{ items, nextCursor }shape but always returns everything and a null cursor. - Deleting from the client, and editing a service entry or a document. The API has
PATCH/DELETEfor all three; only the bike edit screen (#/bikes/:id/edit) calls any of them so far. PATCH /bikes/{id}answers 204, where 03 §3 specifies 200 with the updated bike. The edit screen returns to the bike page, which re-reads it, so nothing is broken — but the contract and the code still disagree.