Odometer is a local-first CLI, TUI and browser application for tracking the true cost of owning a vehicle.
Status: v0.2.0 is a GitHub-only testing release. Odometer is still early-stage software and is not published to PyPI yet.
It helps you answer a simple question:
What does this car actually cost me?
Odometer records fuel, servicing, insurance, tax, repairs, finance, tyres, parking, tolls, fines, and other ownership costs. It calculates total spend, cost per mile, UK MPG, rolling averages, and monthly or annual summaries.
Odometer is designed to be:
- Local-first
- Privacy respecting
- Multi-vehicle
- CLI-first
- TUI included
- Docker-friendly
- Local browser dashboard with a shared service-backed API
No cloud. No accounts. No subscriptions. Your data stays with you.
- Vehicle management by UK registration
- Expense logging by category
- Fuel logging with litres, mileage, station, and full/partial fills
- SQLite storage using an OS-appropriate local data directory
ODOMETER_DB_PATHoverride for tests, Docker, and portable use- GBP money handling using integer pence
- UK MPG calculations using imperial gallons
- Running and ownership cost per mile
- Rolling averages
- Category breakdowns
- Monthly and annual summaries
- Textual TUI dashboard, tables, summaries, and terminal bar charts
- Docker and Docker Compose support for CLI/TUI
- Local browser dashboard and shared service-backed API
- JSON/CSV exports and online SQLite backups
You need Python 3.12 or newer (below 4.0), Git, and Poetry 2.4.x. The project is not published to PyPI; run it from this repository.
git clone https://github.com/joe-mccarthy/odometer.git
cd odometer
poetry install
poetry run odometer --helpInitialise your local database:
poetry run odometer initAmounts such as --amount and --purchase-price are in GBP pounds, mileage is
in miles, and fuel quantities are in litres. Enter the complete odometer reading
for --mileage, rather than the distance travelled since the last record.
Expense and fuel dates default to today; use --date YYYY-MM-DD for older records.
Vehicle registrations are normalised, so "AB12 CDE" can be used as AB12CDE.
Add a vehicle:
poetry run odometer vehicle add \
--registration "AB12 CDE" \
--make Ford \
--model Kuga \
--year 2019 \
--initial-mileage 42000 \
--fuel-tank-litres 55 \
--purchase-price 12500Log an expense:
poetry run odometer expense add \
--vehicle AB12CDE \
--category service \
--amount 249.99 \
--mileage 43000 \
--description "Annual service"Log fuel:
poetry run odometer fuel add \
--vehicle AB12CDE \
--litres 45.2 \
--amount 68.50 \
--mileage 43420 \
--station Tesco \
--fullFuel logs require the vehicle fuel tank size. Set it when adding the vehicle, with
vehicle set-fuel-tank, or with fuel add --fuel-tank-litres on the first fill.
Show a summary:
poetry run odometer summary --vehicle AB12CDE- UK MPG uses imperial gallons (4.54609 litres per gallon). The first full
tank establishes a baseline; a later full tank at a higher mileage closes the
first measurable segment. Partial fills between them are included. Use
fuel add --partialwhen you do not fill the tank; fills default to--full. - Running cost per mile divides recorded fuel and non-fuel expenses by miles driven since the vehicle's initial mileage. A later odometer reading is needed before a cost per mile can be calculated.
- Ownership cost per mile also includes the purchase price. It is available only when a purchase price is recorded; it does not estimate depreciation or subtract a resale value.
A single fuel log is enough to record spending, but it will not produce MPG yet.
Use --help on a command group or subcommand to see its options:
poetry run odometer vehicle --help
poetry run odometer fuel add --helpCommon commands:
poetry run odometer config show
poetry run odometer vehicle list
poetry run odometer vehicle show AB12CDE
poetry run odometer vehicle set-fuel-tank AB12CDE --litres 55
poetry run odometer vehicle archive AB12CDE
poetry run odometer vehicle sold AB12CDE
poetry run odometer vehicle delete AB12CDE
poetry run odometer expense list --vehicle AB12CDE
poetry run odometer expense delete EXPENSE_ID
poetry run odometer fuel list --vehicle AB12CDE
poetry run odometer fuel delete FUEL_LOG_ID
poetry run odometer summary
poetry run odometer summary monthly --vehicle AB12CDE --year 2026
poetry run odometer summary annual --vehicle AB12CDE
poetry run odometer summary categories --vehicle AB12CDE
poetry run odometer calc mpg --vehicle AB12CDE
poetry run odometer calc cost-per-mile --vehicle AB12CDEDedicated fuel logs are the preferred way to record fuel. FUEL expenses are supported, but when fuel logs exist for the same scope, Odometer uses fuel logs as the source of truth to avoid obvious double counting.
Start the terminal interface:
poetry run odometer tuiThe TUI includes:
- Dashboard
- Vehicle overview
- Vehicle detail
- Expense table
- Fuel log table
- Summary tables
- Monthly spend and mileage bar charts
Use the footer shortcuts to move between screens. When more than one vehicle exists,
dashboard, expense, fuel, and summary screens include a vehicle context selector so you
can switch between one vehicle and all vehicles. With a single vehicle, Odometer hides
that selector and scopes those screens to the only vehicle automatically. Press a on
vehicle, expense, or fuel screens to add records. Press delete on vehicle, expense, or
fuel screens to delete the selected row after confirmation.
After poetry install, start the browser dashboard:
poetry run odometer webOpen http://127.0.0.1:8765. The foreground server initialises the configured
SQLite database, binds only IPv4 loopback and stops with Ctrl+C. Use
poetry run odometer web --port 8876 for another local port. CLI, TUI and web
share the database selected by ODOMETER_DB_PATH; use a disposable path when
trying the app. No Node build, account, remote assets or cloud service is needed.
Host/origin checks protect browser mutations; reverse proxies and LAN hosting
are unsupported. Any process running as your local user can access your data.
The dashboard supports vehicle context, fuel and expenses, summaries, tank updates, sold/archive status and confirmed deletion. Money entry is GBP; storage remains integer pence. MPG uses full-to-full imperial gallons including intervening partial fills. Export and online backup are available through Data tools and the CLI commands below.
Record histories use independent 50-row pages, newest date then ID first. Apply
inclusive date filters (and expense category), or Clear filters. Context and local
record changes restart paging; year affects summaries only. Refresh keeps filters
and starts page one after external changes; traversal is stable on unchanged data.
Summaries always use complete history. API lists accept offset (default 0),
limit (default 50, maximum 200), and optional page=true for an object containing
items, offset, limit and has_next; ordinary calls retain array responses.
CLI fuel list and expense list also accept --offset and --limit (1–200).
Current limits: reactivation is not provided.
Chromium has been tested, including bounded history traversal beyond 1,000 rows;
Firefox/WebKit, native devices and screen readers remain unverified. See browser smoke steps.
Before a manual file-copy backup or restore, stop the web server as well as CLI/TUI
sessions. The backup command supports a running database through SQLite online
backup; restore only while all Odometer processes are stopped.
Build the image:
docker compose buildRun commands:
docker compose run --rm odometer --help
docker compose run --rm odometer init
docker compose run --rm odometer vehicle list
docker compose run --rm odometer tuiDocker stores data in ./data on the host and /data/odometer.db in the container.
By default, Odometer stores one SQLite database named odometer.db in the local user data directory for the current operating system.
Show the active paths:
poetry run odometer config showPrefer poetry run odometer backup odometer-backup.db, which safely snapshots a
running database. See the export and backup commands below. For a manual copy or
restore, stop all CLI, TUI and web processes first. Docker Compose stores the file
at ./data/odometer.db. Restore by replacing the configured database while all
processes are stopped, or point ODOMETER_DB_PATH at a copy of the backup.
These commands read the configured local database (ODOMETER_DB_PATH applies),
include inactive vehicles, and never overwrite an existing destination. Initialise
new databases with poetry run odometer init first. Destination parent directories must exist.
poetry run odometer export json odometer-export.json
poetry run odometer export csv vehicles vehicles.csv
poetry run odometer export csv fuel fuel.csv
poetry run odometer export csv expenses expenses.csv
poetry run odometer backup odometer-backup.dbJSON format version 1 contains format_version, vehicles, fuel, and expenses.
Records retain identifiers, relationships, timestamps, dates, nulls and all model
fields. Monetary totals remain integer pence; fractional fuel unit prices retain
their stored precision. CSV uses the same field names, ISO dates, lowercase
booleans and empty cells for nulls (CSV cannot distinguish null from empty text).
Exports contain raw records rather than calculated totals: fuel-category expenses
and fuel logs remain separate, preserving existing reporting deduplication rules.
No import command is provided.
Each export reads a consistent SQLite snapshot. Backups use SQLite's online backup
API, include committed WAL data, and pass PRAGMA integrity_check before publication.
They are standalone SQLite files, suitable for opening with SQLite or using as an
ODOMETER_DB_PATH. Commands never initialise or migrate the source database. Output
is created locally with owner-only permissions; exports and backups contain your
private records, so choose their storage location accordingly. Publication requires
a filesystem supporting hard links in the destination directory.
ODOMETER_DB_PATH: override the SQLite database path.
Example:
export ODOMETER_DB_PATH="$PWD/odometer.db"
poetry run odometer init
poetry run odometer tuiThis shell syntax is for Bash and similar shells. In PowerShell, set
$env:ODOMETER_DB_PATH = "$PWD/odometer.db" before running the same Poetry commands.
Keep the override set for subsequent commands to use the same database.
Prerequisites:
- Python 3.12 or newer
- Poetry 2.4.x
- Docker, if you want to test the container build
Set up a local development environment:
poetry install
poetry run odometer --helpUse a project-local database while developing so you do not touch your normal Odometer data:
ODOMETER_DB_PATH=./odometer-dev.db poetry run odometer init
ODOMETER_DB_PATH=./odometer-dev.db poetry run odometer tuiRun the same core checks used by pull request CI:
poetry check --lock
poetry run ruff format --check .
poetry run ruff check .
poetry run mypy odometer
poetry run pytestFor a walkthrough of the package structure, data flow, business rules, and tests, see docs/CODEBASE.md.
Generate coverage reports:
poetry run pytest --cov=odometer --cov-report=term-missing --cov-report=xml --cov-report=htmlThe HTML report is written to htmlcov/, and the XML report is written to coverage.xml.
Check the Docker image locally:
docker compose build
docker compose run --rm odometer --helpContribution guidelines:
- Keep changes focused and covered by tests when they affect behavior.
- Prefer the existing service, repository, CLI, and TUI patterns before adding new abstractions.
- Run formatting, linting, type checking, and tests before opening a pull request.
- Update this README or the changelog when behavior, commands, or release process details change.
GitHub Actions run pull request checks for formatting, linting, typing, tests,
coverage artifacts, and Docker builds. Releases are created as GitHub Releases
from version tags in the form vX.Y.Z; the tag version must match the version
in pyproject.toml.
Odometer is GitHub-only while release testing is in progress. The release workflow builds distribution artifacts, uploads checksums, and publishes a GitHub Release. It does not publish to PyPI.
The release includes:
- Core CLI commands for vehicles, expenses, fuel logs, summaries, and calculations.
- SQLite-backed local storage with Docker support.
- Running and ownership cost calculations, UK MPG, rolling averages, category breakdowns, and monthly or annual summaries.
- Textual TUI screens for dashboard, vehicles, vehicle detail, expenses, fuel, and summaries.
- Vehicle-scoped TUI context, confirmed deletion flows, and terminal bar charts for monthly spend and mileage.
Odometer is open source and licensed under the GNU General Public License v3.0 only (GPL-3.0-only).
See the LICENSE file for details.
Data tools downloads full version-1 JSON, Vehicles/Fuel/Expenses CSV, or an integrity-checked SQLite online backup. Files include all vehicles (including sold and archived) regardless of dashboard filters, and contain private data. JSON/CSV contain only vehicles, fuel and expenses; SQLite backups retain all persisted tables, including reminders. Downloads stay local and accept no server filesystem paths. Restore a backup only while all Odometer processes are stopped. The server prepares each completed file in a private temporary directory and removes it before returning bytes; preparation requires memory for the file.
odometer reminder add --vehicle AB12CDE --title "Annual service" --category SERVICE --due-date 2027-01-20 --due-mileage 42000
creates a per-vehicle occurrence. reminder list shows status, thresholds and the
latest dated fuel/expense mileage with its source; --upcoming narrows to due,
upcoming and unknown mileage status. Default lists exclude sold/archived vehicles;
--include-inactive reveals their retained reminders and history.
Due means date is today/past or latest recorded mileage reaches the threshold. Upcoming means not due and within 30 calendar days or 1,000 miles, inclusive. Dates use the local calendar. Mileage observations are selected by record date then ID across fuel and expenses, rather than maximum miles. Initial mileage alone is an undated baseline and cannot establish mileage status. Observations older than 90 days remain usable but are labeled stale; an unreached stale threshold is unknown unless a date establishes due/upcoming or known mileage establishes due.
reminder edit ID --title "Annual service" --category SERVICE --due-date 2027-02-20
replaces the title/category/thresholds while preserving ID/vehicle; omitted
thresholds are cleared. Supply at least one threshold. reminder complete ID
retains completed history. Optional --next-due-date and/or --next-due-mileage
create one linked next occurrence atomically. There is no inferred recurrence.
reminder delete ID asks for confirmation. Browser Reminders offers the same
operations, grouped statuses, retained completion history and mileage provenance.
No email, push, account or external lookup is involved. SQLite backups preserve
all occurrences; version-1 JSON and CSV still contain vehicles/fuel/expenses only.
Correct records with odometer fuel edit RECORD_ID --amount 45.01 --partial
or odometer expense edit RECORD_ID --amount 12.34 --clear-mileage.
Only supplied CLI fields change; pass an empty string to clear optional text.
The browser offers Edit beside each record, and the TUI uses e on a selected row.
Corrections preserve IDs and vehicle relationships and validate neighboring fuel readings.
HTTP clients can replace editable fields with PUT /api/fuel/{id} and
PUT /api/expenses/{id} using the same local Origin and request headers as creation.
Preview first: odometer import export.json --conflict-policy reject --dry-run.
Apply: odometer import export.json --conflict-policy reject --backup before-import.db.
The backup option uses the online SQLite backup service and aborts import if it fails.
For a new database, omit --backup; there is no existing database to back up.
Import is append-only: the required reject policy rejects duplicate IDs within
an entity, existing target IDs and duplicate active registrations. It never merges,
replaces or regenerates IDs. Every fuel/expense must reference a vehicle in the
file. Unsupported versions, schema errors and domain errors reject the entire
file with located diagnostics and intended counts. Commit rechecks conflicts under
a writer lock and writes all three entities in one transaction. Dry runs never
initialize the target, write records or create a backup. Dates, IDs, nulls, integer
pence and full/partial fills are preserved, including fuel-expense deduplication.
Version 1 covers vehicles, fuel and expenses only; it does not restore reminders.
Use SQLite backups for a full database snapshot. Keep exports and backups private.
HTTP monetary integer fields retain numeric values and include additive _exact
string companions for lossless browser display, including aggregate totals and
rounded monthly averages beyond int64. Fuel PUT preserves stored full/partial
state when is_full_tank is omitted; explicit true/false changes it. Vehicle
deletion also removes active and completed reminders; all confirmation surfaces
disclose this cascade and results include the reminder count.