No description
  • C# 55.5%
  • JavaScript 37.8%
  • CSS 3.9%
  • Python 1.4%
  • Shell 0.7%
  • Other 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ludwig Andersson 0f88d6cde8
All checks were successful
Build and publish / test-js (push) Successful in 3s
Build and publish / test-dotnet (push) Successful in 18s
Build and publish / publish (push) Successful in 7s
Update maintenance schedule
2026-09-30 12:37:23 +00:00
.devcontainer Rename to hourmeter 2026-09-21 10:00:19 +02:00
.forgejo/workflows Update build process to push images to Forgejo's container registry 2026-09-21 10:59:44 +02:00
css Improve Upcoming maintenance 2026-09-30 11:48:40 +00:00
deploy Update build process to push images to Forgejo's container registry 2026-09-21 10:59:44 +02:00
docs Update maintenance schedule and add pre-ride 2026-09-30 10:21:35 +00:00
js Update maintenance schedule 2026-09-30 12:37:23 +00:00
src Improve Upcoming maintenance 2026-09-30 11:48:40 +00:00
tests Improve Upcoming maintenance 2026-09-30 11:48:40 +00:00
tools Rename to hourmeter 2026-09-21 10:00:19 +02:00
.dockerignore Rename to hourmeter 2026-09-21 10:00:19 +02:00
.editorconfig Add implementation 2026-09-15 17:23:54 +00:00
.env.example Rename to hourmeter 2026-09-21 10:00:19 +02:00
.gitignore Remove bin and obj folders 2026-09-21 11:10:19 +02:00
.prettierignore Add implementation 2026-09-15 17:23:54 +00:00
.prettierrc Initial commit 2026-09-14 10:39:14 +00:00
compose.yaml Rename to hourmeter 2026-09-21 10:00:19 +02:00
Directory.Build.props Initial commit 2026-09-14 10:39:14 +00:00
Directory.Packages.props Add implementation 2026-09-15 17:23:54 +00:00
Hourmeter.drawio Add implementation 2026-09-15 17:23:54 +00:00
Hourmeter.slnx Add implementation 2026-09-15 17:23:54 +00:00
index.html Add implementation 2026-09-15 17:23:54 +00:00
README.md Update maintenance schedule and add pre-ride 2026-09-30 10:21:35 +00:00

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=Lax and the client is same-origin, which covers the common cases, but 07 §2 describes an X-CSRF header the server does not yet issue or check.
  • Idempotency keys on POST /bikes and POST /bikes/{id}/services, and ETag/If-Match on bike updates (03 §1).
  • Paging. GET /bikes answers 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/DELETE for 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.