- Python 50.6%
- TypeScript 46.1%
- PLpgSQL 2.2%
- JavaScript 0.7%
- CSS 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Optimized the ui of README and inserted screenshots Includes now a demo compose yaml with demo data |
||
| backend | ||
| docs/screenshots | ||
| frontend | ||
| scripts | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| CODE_OF_CONDUCT.md | ||
| CONTRIBUTING.md | ||
| docker-compose.demo.yml | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| LICENSE | ||
| README.md | ||
Home Energy Usage Tracker
Home Energy Usage Tracker is a self-hosted, single-user application for recording meter
readings (electricity import/export/production, gas, cold and garden water) and managing
tariff periods and monthly advance payments. Based on the readings it computes
consumption, costs, and annual projections — including meter replacement chains,
wastewater billing, and EEG 2009/Simple feed-in compensation — and visualizes the results in
per-meter charts and year-over-year overviews. Everything runs locally via a single
docker compose up -d: all data stays in your own PostgreSQL instance, with no cloud
services, no authentication, and no external network access.
Architecture
The production stack (docker-compose.yml) consists of three containers on the
pre-created Docker network energy-net. Only the frontend is reachable from the host;
the backend is proxied internally through nginx, and the database additionally accepts
localhost-only connections for administration.
flowchart LR
user(("User<br/>(browser)"))
admin(("Host admin<br/>(psql)"))
subgraph net ["Docker network: energy-net"]
fe["frontend<br/>nginx :80<br/><i>serves React build,<br/>reverse-proxies /api</i>"]
be["backend<br/>FastAPI / uvicorn :8000<br/><i>no host port;<br/>runs alembic upgrade head<br/>on start</i>"]
db[("db<br/>PostgreSQL 16 :5432<br/><i>bind mount:<br/>.dockerdata/postgres</i>")]
end
user -- "http://localhost:3500" --> fe
fe -- "/api proxy → backend:8000 (internal)" --> be
be -- "DATABASE_URL → db:5432 (internal)" --> db
admin -. "127.0.0.1:5432<br/>(localhost-only, not exposed)" .-> db
Screenshots
![]()
![]()
Features
- Track multiple meters: Gas, cold/garden water (m³), electricity import/export/production (kWh) — the unit is derived automatically from the meter type
- Record readings: Simple form to add meter readings with dates and optional notes; edit or delete existing readings directly in the year detail tables via inline dialog. By default only current meters are shown — replaced meters can be displayed via an "Alte Zähler anzeigen" toggle.
- Visualize consumption: Separate line charts per energy type (Strom, Wasser, Gas) on the years-overview page, plus per-meter charts on the year detail page showing usage trends over time, with toggle between absolute meter readings and cumulative consumption since Jan 1 (starts at 0, even-number axis bounds)
- Custom time-range analysis: The
/custom-rangepage ("Zeitraum" in the main nav, directly after "Jahresübersicht") lets you pick any from/to date (default: current year) and filter by energy type (Strom/Wasser/Gas). Each active area shows a combined multi-line chart and real readings in per-meter tables side-by-side, with consumption and value between readings (no projections), fetched viaGET /api/costs/consumption-timeseries. The cost column is labelled "Verbrauch seit letzter Ablesung" and "Kosten seit letzter Ablesung"; for Einspeisung/Erzeugung it is "Vergütung seit letzter Ablesung". For cost meters each value is broken down on a second line into Arbeitspreis / Grundpreis / Messstellenbetrieb. Einspeisung/Erzeugung without a purefeed_intariff (e.g. EEG-2009 or none) show "-" instead of "—". When production + export meters exist, a summary card additionally shows the EEG-2009-Einspeisevergütung for the selected period (reusingGET /api/electricity/feed-in-compensationwith thefeed_in_eeg2009tariff), broken down into gross compensation and the deducted metering point fee. - Tariff management: Track price per unit and base fees for different periods, with optional comment field for provider name and category filter buttons (Strom Bezug/Gas/Wasser) in the list view
- Advance payment management: Track monthly Abschläge per meter with category (gas, cold water, wastewater, power import), time-range validity, overlap protection, optional comment field, and an "Alle" button showing all categories at once in the list view
- Meter replacement chain: When a physical meter is swapped, link the new meter to its predecessor via
replaces_meter_id. Cost calculations automatically follow the chain, summing each meter's contribution — no negative consumption from a counter reset on the new device. The edit dialog only proposes same-type meters as predecessors and excludes the meter being edited itself (a meter can never be its own predecessor), while keeping an already-selected predecessor selectable. Meter types are editable via forms but changing a meter with existing readings is rejected by the API. - Advance payment comparison: Compare paid/planned monthly Abschläge against the estimated or actual annual cost to see whether you'll receive a refund (Guthaben) or owe a Nachzahlung
- Local deployment: All data stays on your machine, no cloud services
- No authentication: Single-user, local operation only
Tech Stack
Backend
- Python 3.13 with FastAPI
- SQLAlchemy 2.0 with mapped_column syntax
- PostgreSQL 16 database
- Pydantic v2 for data validation
- Alembic for database migrations
- uv as package manager
Frontend
- React 19 with TypeScript (upgraded from 18, Sept 2026)
- Vite 7 for build tooling (upgraded from 5, Sept 2026)
- Custom
useFetchhook (src/hooks/useFetch.ts) for all page data loading — React 19 lint-compliant (no setState-in-effect), seefrontend/README.mdsection 11 - shadcn/ui for UI components
- Recharts for data visualization
- Tailwind CSS for styling
- Vitest 3 for testing with jsdom (upgraded from 2, Sept 2026)
- ESLint 9 with flat config (upgraded from 8, Sept 2026) — see
frontend/README.mdfor React Hooks status
Deployment
- Docker Compose with 3 services
- PostgreSQL database container
- FastAPI backend container
- React frontend container
Getting Started
Prerequisites
- Docker and Docker Compose
- (Optional) Node.js 18+ and Python 3.13 for local development (uv provisions a managed CPython automatically if none is installed)
Quick Start with Docker
-
Clone the repository:
git clone https://codeberg.org/Kamlkatze/home-energy-usage-tracker.git cd home-energy-usage-tracker -
Copy environment variables:
cp .env.example .env -
Create the Docker network once (only needed the first time; the compose file references the pre-existing
energy-netnetwork):docker network create energy-net -
Start all services:
docker compose up -d -
Access the application:
- Frontend: http://localhost:3500
Note: The backend exposes no host port and is not reachable from outside the Docker network — the frontend (nginx) proxies
/apito it internally. The API docs are therefore only available from inside the backend container and not from your browser.
Network exposure of the database: The db service binds PostgreSQL only to 127.0.0.1:5432, so you can reach it from the host via psql -h localhost -p 5432 -U postgres, but it is not exposed to the outside network.
Required password: POSTGRES_PASSWORD must be set (no default fallback). If it is missing or empty, docker compose up fails with an error instead of running with a weak default password.
Demo Data (Fake Data)
scripts/demo_data.sql contains a realistic fake dataset (Jan 2024 – Oct 2026) for
screenshots and trying out the app: 7 meters (including a gas meter replacement chain),
~205 monthly readings with seasonal patterns, tariff periods (including EEG 2009
feed-in and wastewater), and advance payments for all categories.
Warning: Only load it into an empty database — the inserts use fixed primary key IDs and will fail on (or mix with) existing data. Both bounds of the transaction are rolled back automatically on any error.
Load it after the migrations have run (the backend container applies them on start):
# Containerized (adjust the container name and target DB as needed):
cat scripts/demo_data.sql | docker exec -i <db-container> psql -U postgres -d energy_tracker
# Native PostgreSQL:
psql -U postgres -d energy_tracker -f scripts/demo_data.sql
Ready-made demo stack: docker-compose.demo.yml starts a complete demo
environment (own PostgreSQL on 127.0.0.1:5434, backend without host port, frontend on
http://localhost:3001) alongside the dev stack. A seed service wipes the demo DB
and reloads scripts/demo_data.sql on every start, so the dataset is always fresh:
docker compose -f docker-compose.demo.yml up -d --build
Stop it with (containers are removed, the demo DB volume .dockerdata/postgres_demo
is kept and re-seeded on the next start):
docker compose -f docker-compose.demo.yml down
Environment Configuration
The application uses a centralized .env file for configuration. When you copy .env.example to .env, you can customize these settings:
Database Configuration:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=energy_tracker
POSTGRES_DB_TEST=energy_tracker_test # for development/testing
Network Configuration:
DATABASE_HOST=localhost # for native development (outside Docker)
DATABASE_PORT=5432
Backend Configuration:
API_V1_PREFIX=/api
PROJECT_NAME=Home Energy Usage Tracker
CORS_ORIGINS=["http://localhost:3500", "http://localhost:5173"]
Frontend Configuration (native development only):
VITE_API_URL=http://localhost:8000 # only used outside Docker
How it works:
- Docker Compose files use
${VARNAME:-default}substitution to load values from.env POSTGRES_PASSWORDis required (no default) in production — the compose file fails fast if it is not set- Database URLs (
DATABASE_URL,DATABASE_URL_ASYNC) are automatically constructed from the credentials - In production,
VITE_API_URLis passed as a build argument and defaults to/apifor nginx proxy routing - In development,
VITE_API_URLis empty for relative API calls through Vite proxy - The Vite dev-server proxy (
server.proxyinfrontend/vite.config.ts) forwards/apiusingprocess.env.VITE_PROXY_TARGET, falling back tohttp://localhost:8000when unset:- Containerized dev:
docker-compose.dev.ymlsetsVITE_PROXY_TARGET: http://backend:8000as a container environment variable, so the frontend container reaches the backend via its docker-compose service name (backend, port8000) - Native dev: the variable is unset, so the proxy falls back to
http://localhost:8000 - The backend mounts its routes under
/api(API_V1_PREFIX=/api), so/apiis passed through to the backend unchanged (no path rewrite)
- Containerized dev:
Native Development (Without Docker)
If you prefer to run services directly without Docker:
-
Setup Database:
docker compose up db -d # or use a locally installed PostgreSQL -
Setup Backend:
cd backend uv venv source .venv/bin/activate # On Windows: .venv\Scripts\activate uv pip install -e . -
Setup Frontend:
cd frontend npm install -
Start Services:
# In terminal 1: Backend cd backend uv run uvicorn app.main:app --reload # In terminal 2: Frontend cd frontend npm run dev -
Access:
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000
- API Documentation: http://localhost:8000/docs
Note: The .env file must have DATABASE_HOST=localhost and VITE_API_URL=http://localhost:8000 for native development.
Local Development with Docker
For full Docker development (hot-reload for both frontend and backend):
-
Start development stack:
docker compose -f docker-compose.dev.yml up -d -
Access:
- Frontend: http://localhost:3000 (Vite dev server with hot reload)
- Backend: http://localhost:8000/docs (FastAPI with auto-reload)
Key differences in development setup:
- Frontend: Vite dev server on port 3000 (not nginx)
- Backend: Auto-reload enabled for code changes
- Database: Healthcheck ensures backend waits for ready DB
- Volumes: Code is mounted for live editing
- Proxy Configuration:
- Docker dev: API calls go through Vite proxy to
backend:8000 - Native dev: API calls go directly to
localhost:8000(no Vite proxy) - Production: API calls go through nginx proxy (on port 3500) to
backend:8000
- Docker dev: API calls go through Vite proxy to
Backend Setup
Stopping the Stacks
Dev Stack (docker-compose.dev.yml)
The Dev Stack includes these containers:
home-energy-usage-tracker_frontend_1- Frontend development server (Vite)home-energy-usage-tracker_backend_1- Backend FastAPI server with hot-reloadhome-energy-usage-tracker_db_1- PostgreSQL main databasehome-energy-usage-tracker_db-test_1- PostgreSQL test database
Stop the Dev Stack:
# Stop and remove all Dev Stack containers
docker compose -f docker-compose.dev.yml down
# Stop containers without removing (for faster restart)
docker compose -f docker-compose.dev.yml stop
Prod Stack (docker-compose.yml)
The Prod Stack includes these containers:
home-energy-usage-tracker_frontend_1- Frontend production build (nginx)home-energy-usage-tracker_backend_1- Backend production (uvicorn)home-energy-usage-tracker_db_1- PostgreSQL main database
Stop the Prod Stack:
# Stop and remove all Prod Stack containers
docker compose down
# Stop containers without removing
docker compose stop
Note: The Prod Stack uses the pre-defined external network
energy-net, which must be created once before the first start (docker network create energy-net). Containers reference the database only on127.0.0.1:5432(host-reachable, not exposed externally); the backend publishes no host port at all.
⚠️ Data Loss Warning: docker compose down removes containers but keeps volumes. docker compose down -v removes volumes and all data!
General Commands
# Show container status
docker compose -f docker-compose.dev.yml ps
docker compose ps
# View logs of all containers
docker compose -f docker-compose.dev.yml logs
# Follow logs (Ctrl+C to stop)
docker compose -f docker-compose.dev.yml logs -f
# Show all containers for this app
docker ps -a --filter name=home-energy-usage-tracker
API Endpoints
Meters
GET /api/meters/current- Return only current (non-replaced) meters — newest in each replacement chain plus standalone meters. Used by the frontend to show only active meters (with an optional toggle for replaced meters in the reading form).GET /api/meters/types- List all meter types with German label and fixed unit. Example response:[ { "value": "gas", "label": "Gas", "unit": "m³" }, { "value": "water_cold", "label": "Kaltwasser", "unit": "m³" }, { "value": "water_garden", "label": "Gartenwasser", "unit": "m³" }, { "value": "electricity_import", "label": "Strom Bezug", "unit": "kWh" }, { "value": "electricity_export", "label": "Strom Einspeisung", "unit": "kWh" }, { "value": "electricity_production", "label": "Strom Erzeugung", "unit": "kWh" } ]GET /api/meters/- List all metersPOST /api/meters/- Create a new meter (optional field:replaces_meter_id— must reference an existing meter of the same type, self-references return 422)GET /api/meters/{id}- Get meter detailsPUT /api/meters/{id}- Update meter (same validation forreplaces_meter_idapplies; changingmeter_typeis rejected with 422 when readings exist for this meter)DELETE /api/meters/{id}- Delete meterGET /api/meters/{id}/readings- Get readings for a meterGET /api/meters/{id}/projection- Get annual consumption projection for a meter (see Projection Endpoint)
Readings
GET /api/readings/- List all readingsPOST /api/readings/- Create a new readingGET /api/readings/{id}- Get reading detailsPUT /api/readings/{id}- Update readingDELETE /api/readings/{id}- Delete reading
Tariff Periods
GET /api/tariffs/- List all tariff periods (optional query parameter:tariff_kind)POST /api/tariffs/- Create a new tariff period (optionalcommentfield for provider name)GET /api/tariffs/{id}- Get tariff period detailsGET /api/tariffs/{tariff_id}/usage- Get tariff usage info (reading count within validity range, across the full meter chain)PUT /api/tariffs/{id}- Update tariff period (includingcomment)DELETE /api/tariffs/{id}- Delete tariff period
TariffKind Values
consumption- Price per unit for consumption (may include optionalbase_fee_monthlyandmetering_fee_monthly)feed_in- Simple feed-in tarifffeed_in_eeg2009- EEG 2009 feed-in tariff (includeseeg_tier1_rate,eeg_tier2_rate,metering_point_fee_yearly)self_consumption- Self-consumption ratewastewater- Price per unit for wastewater (billed on the cold-water meter)
Note: Previously separate base_fee and metering_fee tariff kinds have been merged into the consumption tariff kind as optional monthly fee fields.
Example POST /api/tariffs/ request:
{
"meter_id": 1,
"tariff_kind": "consumption",
"valid_from": "2024-01-01",
"valid_to": "2024-12-31",
"price_per_unit": "0.3567",
"base_fee_monthly": "15.50",
"metering_fee_monthly": "5.80",
"comment": "Stadtwerke Musterstadt"
}
Example GET /api/tariffs/?tariff_kind=consumption response:
[
{
"id": 1,
"meter_id": 1,
"tariff_kind": "consumption",
"valid_from": "2024-01-01",
"valid_to": "2024-12-31",
"price_per_unit": "0.3567",
"base_fee_monthly": "15.50",
"metering_fee_monthly": "5.80",
"created_at": "2024-01-01T12:00:00Z"
}
]
Advance Payments
GET /api/advance-payments/- List all advance payments (optional query parameters:meter_id,category)POST /api/advance-payments/- Create a new advance payment (optionalcommentfield)GET /api/advance-payments/{id}- Get advance payment detailsPUT /api/advance-payments/{id}- Update advance payment (includingcomment)DELETE /api/advance-payments/{id}- Delete advance payment
Each advance payment carries a category (gas, cold_water, wastewater, power_import) that determines which meter reference is used:
- Non-wastewater categories (
gas,cold_water,power_import): reference the meter viameter_id.water_cold_meter_idandwater_garden_meter_idmust be null (422 otherwise). wastewater:meter_idmust be null,water_cold_meter_idis required.water_garden_meter_idis optional — when omitted, the entire cold-water consumption counts as wastewater.
DB constraints:
ck_advance_payment_wastewater:NOT (category = 'wastewater') OR (meter_id IS NULL AND water_cold_meter_id IS NOT NULL)ck_advance_payment_non_wastewater:(category = 'wastewater') OR (meter_id IS NOT NULL AND water_cold_meter_id IS NULL AND water_garden_meter_id IS NULL)- Overlap prevention via partial EXCLUDE constraints: non-wastewater periods must not overlap for the same
(meter_id, category); wastewater periods must not overlap for the samewater_cold_meter_id
Example POST /api/advance-payments/ request (non-wastewater):
{
"category": "power_import",
"meter_id": 1,
"valid_from": "2024-01-01",
"valid_to": "2024-12-31",
"monthly_amount": "120.00",
"comment": "Stadtwerke Musterstadt"
}
Example POST /api/advance-payments/ request (wastewater):
{
"category": "wastewater",
"water_cold_meter_id": 2,
"water_garden_meter_id": 3,
"valid_from": "2024-01-01",
"valid_to": "2024-12-31",
"monthly_amount": "85.00"
}
Example GET /api/advance-payments/ response:
[
{
"id": 1,
"category": "power_import",
"meter_id": 1,
"water_cold_meter_id": null,
"water_garden_meter_id": null,
"valid_from": "2024-01-01",
"valid_to": "2024-12-31",
"monthly_amount": "120.00",
"comment": null,
"created_at": "2024-01-01T12:00:00Z"
}
]
Costs
GET /api/costs/?meter_id={id}&from={date}&to={date}- Calculate the consumption cost for one meter between two dates (query parameters:meter_id,from,to). Returns 404 when no reading or no covering tariff period is found, 422 whenfrom > toor the computed consumption is negative.
Multi-period support: If the date range spans multiple tariff periods, consumption is allocated proportionally by days in each overlapping period. Base fees are prorated using calendar-accurate month lengths (full months count as 1, partial months by actual days in that month). The price_per_unit field in the response shows the first period's price for backward compatibility; the total cost correctly accounts for all periods.
Meter replacement chain support: If the requested meter has a replaces_meter_id (directly or transitively), the endpoint automatically follows the chain. Consumption is computed as the sum of each meter's contribution over its active segment (determined by the first reading date of the successor). Tariffs are looked up per meter in the chain so a tariff change at the predecessor is fully reflected. Rounding happens only once at the final output boundary — never per segment — to avoid accumulated cent errors.
Example GET /api/costs/?meter_id=1&from=2024-01-10&to=2024-03-10 response:
{
"meter_id": 1,
"from_date": "2024-01-10",
"to_date": "2024-03-10",
"consumption": "150.500",
"unit": "kWh",
"price_per_unit": "0.35490",
"base_fee_total": "25.00",
"total_cost": "78.41"
}
GET /api/costs/wastewater/?water_cold_meter_id={id}&water_garden_meter_id={id}&from={date}&to={date}- Calculate the wastewater cost between two dates. Wastewater is a derived value, not a real meter:wastewater_consumption = max(0, cold-water consumption - garden-water consumption), billed against awastewatertariff period attached to the cold-water meter's id (query parameters:water_cold_meter_id,water_garden_meter_id(optional),from,to). Returns 404 when no reading or no covering wastewater tariff period is found, 422 whenfrom > toor the cold-water consumption is negative. Supports multiple tariff periods like the main cost endpoint.
Garden meter omitted vs. garden meter without readings (important distinction)
water_garden_meter_idomitted entirely: treated as "no garden meter installed".garden_consumption = 0and the entire cold-water consumption counts as wastewater. No garden readings are required.water_garden_meter_idisnullin the response.water_garden_meter_idprovided: the garden meter's start/end readings are looked up with the exact same rule as the cold meter. If the garden meter has no readings in the range, the endpoint returns 404 ("Kein Zählerstand im angegebenen Zeitraum gefunden.") — the client explicitly claimed this meter is relevant, so silently treating it as 0 would be wrong.- A garden consumption exceeding the cold consumption is a data quality issue that is not validated here;
wastewater_consumptionis simply floored at 0.
Example GET /api/costs/wastewater/?water_cold_meter_id=1&water_garden_meter_id=2&from=2024-01-10&to=2024-03-10 response:
{
"water_cold_meter_id": 1,
"water_garden_meter_id": 2,
"from_date": "2024-01-10",
"to_date": "2024-03-10",
"cold_consumption": "150.500",
"garden_consumption": "59.500",
"wastewater_consumption": "91.000",
"unit": "m³",
"price_per_unit": "2.10000",
"base_fee_total": "12.00",
"total_cost": "203.10"
}
EEG 2009 Feed-in Compensation
GET /api/electricity/feed-in-compensation?production_meter_id={id}&export_meter_id={id}&import_meter_id={id}&from={date}&to={date}- Calculate the feed-in compensation according to EEG 2009 § 33 Abs. 2. Three meter IDs are accepted as query parameters with no fixed DB relationship (analogous to the wastewater endpoint).import_meter_idis accepted for future extensibility but currently unused.GET /api/electricity/feed-in-compensation-projection?production_meter_id={id}&export_meter_id={id}&year={year}- Projected annual feed-in compensation for a full year, using linear projections for both production and export consumption.
Calculation (EEG 2009 § 33 Abs. 2): P = production_consumption (total generation) E = export_consumption (electricity fed into grid) S = max(0, P − E) (self-consumption) tier1_kwh = min(S, 0.3 × P) tier2_kwh = max(0, S − 0.3 × P) gross_compensation = P × price_per_unit − tier1_kwh × eeg_tier1_rate − tier2_kwh × eeg_tier2_rate metering_point_fee_deducted = days × metering_point_fee_yearly / 365 Total = gross_compensation − metering_point_fee_deducted
The 30 % threshold is a fixed statutory value from EEG 2009 § 33 Abs. 2, not configurable. The tariff is attached to the export meter (tariff_kind=feed_in_eeg2009). Returns 404 when a meter does not exist, has no readings in the range, or no active EEG 2009 feed-in tariff is found. Returns 422 when from > to or consumption is negative.
Known limitation: Meter-replacement-chain support is NOT implemented for this endpoint (first iteration).
Example GET /api/electricity/feed-in-compensation?production_meter_id=1&export_meter_id=2&from=2024-01-10&to=2024-03-10 response:
{
"production_meter_id": 1,
"export_meter_id": 2,
"from_date": "2024-01-10",
"to_date": "2024-03-10",
"production_consumption": "4500.000",
"export_consumption": "2800.000",
"self_consumption": "1700.000",
"tier1_kwh": "1350.000",
"tier2_kwh": "350.000",
"total_compensation": "987.65",
"gross_compensation": "995.87",
"metering_point_fee_deducted": "8.22"
}
Yearly Projection with Cost
GET /api/costs/yearly-projection?meter_id={id}&year={year}&as_of_date={date}- Combine annual consumption projection with cost calculation. Theas_of_dateparameter is optional, defaults to server date (datetime.now(UTC).date()). Returns 200 OK even with insufficient data or missing tariff, 404 if meter not found, 422 for invalid year (outside 2000-2100 range).
Calculation Logic:
- Projection: Uses the same logic as
/api/meters/{id}/projectionto get projected annual consumption - Active tariff: Finds the consumption tariff active on
as_of_datefor the meter - Actual cost: Calculates cost from Jan 1 of
yeartoas_of_dateusing existing cost calculation logic. For completed years (i.e.yearbefore theas_of_dateyear), the period is clamped to Dec 31 ofyear: readings after that date do not inflate the consumption, and base/metering fees are prorated only over the viewed year - Projected cost: Estimates annual cost using projected consumption and active tariff's rates
Edge cases:
- No active tariff →
projected_annual_cost = nullwith explanatory message - Insufficient data for projection →
projected_annual_cost = null(projection basis will be "insufficient_data") - Multiple tariff periods historically → correctly picks the one active on
as_of_date - Gas meter: consumption (m³) is converted to kWh via
gas_z_zahl×gas_brennwertbefore cost calculation, for both actual and projected costs; a tariff lacking these fields → cost fieldsnullwith explanatory message (200) - Actual cost unavailable (no tariff covering the period, negative consumption, missing gas factors) →
actual_cost_to_date = nullwith explanatory message. If the projected path fails too, both reasons are combined into the singlemessagefield (deduplicated, space-separated)
Example GET /api/costs/yearly-projection?meter_id=1&year=2025&as_of_date=2025-06-01 response:
{
"meter_id": 1,
"year": 2025,
"unit": "kWh",
"as_of_date": "2025-06-01",
"actual_consumption_to_date": "250.500",
"projected_annual_consumption": "601.447",
"projected_meter_reading_end_of_year": "2675.447",
"start_reading_value": "2074.000",
"start_reading_date": "2024-12-31",
"basis": "linear_from_prev_year_end",
"actual_cost_to_date": "92.35",
"projected_annual_cost": "221.89",
"message": null
}
Example no active tariff response:
{
"meter_id": 1,
"year": 2025,
"unit": "kWh",
"as_of_date": "2025-06-01",
"actual_consumption_to_date": "250.500",
"projected_annual_consumption": "601.447",
"projected_meter_reading_end_of_year": "2675.447",
"start_reading_value": "2074.000",
"start_reading_date": "2024-12-31",
"basis": "linear_from_prev_year_end",
"actual_cost_to_date": null,
"projected_annual_cost": null,
"message": "Kein gültiger Tarif für diesen Zeitraum gefunden. Kein aktiver Tarif für dieses Datum gefunden."
}
The
messagecombines the actual-cost failure (no tariff for Jan 1 – as_of_date) and the projected-cost failure (no active tariff) into one deduplicated, space-separated string.
Example insufficient data response:
{
"meter_id": 1,
"year": 2025,
"unit": "kWh",
"as_of_date": "2025-06-01",
"actual_consumption_to_date": null,
"projected_annual_consumption": null,
"projected_meter_reading_end_of_year": null,
"basis": "insufficient_data",
"actual_cost_to_date": null,
"projected_annual_cost": null,
"message": "Nicht genügend Zählerstände im Jahr 2025 vorhanden, um eine Hochrechnung zu erstellen."
}
Projection Endpoint
GET /api/meters/{meter_id}/projection?year={year}&as_of_date={date}- Standalone annual consumption projection for a meter (separate from the combined yearly projection with cost above).
Calculation Logic:
- Year completed (year < as_of_date.year): Actual consumption = end.value - start.value (real yearly consumption)
- Year ongoing (year >= as_of_date.year): Linear projection = (actual consumption / elapsed days) * 365
- Insufficient data: When start/end readings missing or start >= end
Start point determination:
- Preference: Reading exactly on December 31st of previous year
- Fallback: First reading in the target year
Example GET /api/meters/1/projection?year=2025&as_of_date=2025-06-01 response (ongoing year):
{
"meter_id": 1,
"year": 2025,
"unit": "kWh",
"actual_consumption_to_date": "250.500",
"days_elapsed": 152,
"projected_annual_consumption": "601.447",
"projected_meter_reading_end_of_year": "2675.447",
"start_reading_value": "2074.000",
"start_reading_date": "2024-12-31",
"basis": "linear_from_prev_year_end",
"message": null
}
Example GET /api/meters/1/projection?year=2024 response (completed year):
{
"meter_id": 1,
"year": 2024,
"unit": "kWh",
"actual_consumption_to_date": "850.250",
"days_elapsed": 366,
"projected_annual_consumption": "850.250",
"projected_meter_reading_end_of_year": "2850.250",
"start_reading_value": "2000.000",
"start_reading_date": "2024-01-15",
"basis": "actual_full_year",
"message": null
}
Example insufficient data response:
{
"meter_id": 1,
"year": 2024,
"unit": "kWh",
"actual_consumption_to_date": null,
"days_elapsed": null,
"projected_annual_consumption": null,
"projected_meter_reading_end_of_year": null,
"start_reading_value": null,
"start_reading_date": null,
"basis": "insufficient_data",
"message": "Nicht genügend Zählerstände im Jahr 2024 vorhanden, um eine Hochrechnung zu erstellen."
}
Basis values:
"linear_from_prev_year_end"- Start reading from December 31st of previous year"linear_from_first_reading_of_year"- Start reading from first reading in target year (fallback)"actual_full_year"- Year completed, actual consumption is final"insufficient_data"- Not enough readings for calculation
Known limitations:
- Uses only two boundary points (start/end) for linear projection, no regression over multiple intermediate values
- Does not account for seasonal consumption patterns
- Negative consumption values (meter rollover) are calculated as-is, not validated or clamped
Years Overview
GET /api/costs/years-overview- Return a years-overview for every logical meter (standalone meter or newest meter in a replacement chain). Consumption, cost, and cost basis are computed per year using the same projection and cost logic as the yearly-projection endpoint. Returns 200 OK even when some meter/year combinations have missing tariffs or insufficient data.
Chain resolution: Only meters that are NOT referenced by any other meter's replaces_meter_id appear as top-level entries. Their consumption/cost history includes the full replacement chain.
Response structure: Each entry contains a list of years where at least one reading exists in the meter's chain. Years without readings are omitted. Cost fields are null when no tariff or insufficient data is available.
Example GET /api/costs/years-overview response:
[
{
"meter_id": 1,
"meter_label": "Gas Zähler",
"meter_type": "gas",
"unit": "m³",
"years": [
{ "year": 2023, "consumption": "1200.500", "cost": "450.20", "cost_basis": "actual" },
{ "year": 2024, "consumption": "980.000", "cost": "410.00", "cost_basis": "projected" }
]
}
]
GET /api/costs/consumption-timeseries?from={date}&to={date}&meter_type={type}- Return a time series of actual readings (not calendar-aggregated) for every logical meter. Each reading is enriched with consumption and cost since the chronologically previous reading (which may lie outside the requested range or in a predecessor meter of the replacement chain). The optionalmeter_typequery parameter (one of the values from theGET /api/meters/typesendpoint, e.g.electricity_import,water_cold,gas) restricts the result to logical meters of that type; when omitted, all types are returned (previous behaviour). Returns 200 always; 422 whenfrom > toormeter_typeis not a valid type.
Chain resolution: Same logical meter identification as /api/costs/years-overview — only meters NOT referenced by any other meter's replaces_meter_id are top-level entries. Predecessor readings count toward the logical meter's history.
Per-point calculation: For each reading within [from, to], the consumption delta is current_value - previous_value (previous reading is the chronologically prior reading anywhere in the chain). Cost is calculated for the sub-period [previous_reading_date, current_reading_date) using the same multi-period tariff logic (gas m³→kWh conversion included). The cost is broken down per point into energy_cost_since_previous (Arbeitspreis), base_fee_since_previous (Grundpreis) and metering_fee_since_previous (Messstellenbetrieb, prorated monthly like the Grundpreis); these three fields sum exactly to cost_since_previous. For electricity_export (Strom Einspeisung) and electricity_production (Strom Erzeugung) meters the value is instead an Einspeisevergütung: it is computed from the feed_in tariff (kWh × rate, no base fee) and reported in cost_since_previous (and energy_cost_since_previous; base_fee_since_previous is null, metering_fee_since_previous is "0.00") — a consumption tariff is ignored for these meter types. Missing tariffs or negative consumption result in null cost/consumption/breakdown fields per point.
Meter/chain omission: Logical meters with zero readings in [from, to] are omitted from the response entirely (no empty points list).
Example GET /api/costs/consumption-timeseries?from=2024-02-01&to=2024-12-31 response:
[
{
"meter_id": 1,
"meter_label": "Strom Zähler",
"meter_type": "electricity_import",
"unit": "kWh",
"points": [
{ "date": "2024-03-01", "value": "1100.000", "consumption_since_previous": "100.000", "cost_since_previous": "35.00", "base_fee_since_previous": "10.00", "metering_fee_since_previous": "0.00", "energy_cost_since_previous": "25.00" },
{ "date": "2024-06-01", "value": "1250.000", "consumption_since_previous": "150.000", "cost_since_previous": "52.50", "base_fee_since_previous": "10.00", "metering_fee_since_previous": "0.00", "energy_cost_since_previous": "42.50" }
]
}
]
Advance Payment Diff (Cost vs. Abschläge)
GET /api/costs/advance-payment-diff?meter_id={id}&year={year}&as_of_date={date}- Calculate the difference and the estimated or actual annual cost for a meter and year.as_of_dateis optional (defaults to server date). Returns 200 even with no advance payments or no tariff, 404 if meter not found, 422 for invalid year (outside 2000-2100 range).
Wastewater mode: Additionally pass water_cold_meter_id (and optionally water_garden_meter_id) to compute the diff for the wastewater category. Note: meter_id is still a required query parameter on this endpoint, but its value is ignored in wastewater mode:
- The annual cost is based on the wastewater consumption
max(0, cold_water_consumption - garden_water_consumption)(same rules as the wastewater cost endpoint, including meter replacement chains) billed against awastewatertariff attached to the cold-water meter total_advance_paymentssumscategory=wastewateradvance payments that reference the cold-water meter viawater_cold_meter_id- If
water_garden_meter_idis omitted,garden_consumption = 0and the entire cold-water consumption counts as wastewater (no subtraction) meter_idin the response is the cold-water meter id
Calculation Logic:
- Annual cost — Uses the same logic as
/api/costs/yearly-projection:- Completed year: actual full-year cost from all tariffs covering the year (
cost_basis: "actual") - Ongoing/future year: projected cost using the tariff active on
as_of_date(cost_basis: "projected")
- Completed year: actual full-year cost from all tariffs covering the year (
- Total advance payments — Sums all
AdvancePaymentrecords for the meter that overlap the year, prorated using calendar-accurate month lengths (full months count as 1, partial months by actual days in that month). This ensures a full-year payment of e.g. 48€/month yields exactly 576€ (48 × 12), not 584€. - Difference —
total_advance_payments - estimated_or_actual_cost(positive = Guthaben, negative = Nachzahlung)
Edge cases:
- No advance payment exists →
total_advance_payments: null,difference: null, message:"Kein Abschlag für dieses Jahr hinterlegt."(still returns 200) - Annual cost unavailable (no tariff, insufficient data, missing gas factors) →
estimated_or_actual_cost: null,cost_basis: null,difference: null - Both cost and advance payment missing → combined message
Example GET /api/costs/advance-payment-diff?meter_id=1&year=2025&as_of_date=2025-06-01 response:
{
"meter_id": 1,
"year": 2025,
"estimated_or_actual_cost": "845.30",
"cost_basis": "projected",
"total_advance_payments": "800.00",
"difference": "-45.30",
"message": null
}
Example no advance payment response:
{
"meter_id": 1,
"year": 2025,
"estimated_or_actual_cost": "845.30",
"cost_basis": "projected",
"total_advance_payments": null,
"difference": null,
"message": "Kein Abschlag für dieses Jahr hinterlegt."
}
Advance Payment Diff By Category
GET /api/costs/advance-payment-diff-by-category?category={cat}&year={year}&meter_id={id}&water_cold_meter_id={id}&as_of_date={date}&water_garden_meter_id={id}- Thin wrapper aroundadvance-payment-diffwith category-based parameter validation. Adds acategoryfield to the response.
Health
GET /health- Health check endpoint, returns{"status": "healthy"}.
Database Schema
erDiagram
advance_payments {
int id PK
enum category "NOT NULL"
int meter_id FK
int water_cold_meter_id FK
int water_garden_meter_id FK
date valid_from "NOT NULL"
date valid_to
numeric monthly_amount "NOT NULL"
string comment
datetime created_at "NOT NULL"
}
meters {
int id PK
enum meter_type "NOT NULL"
string label "NOT NULL"
string meter_number
string unit "NOT NULL"
int replaces_meter_id FK
datetime created_at "NOT NULL"
}
readings {
int id PK
int meter_id FK "NOT NULL"
numeric value "NOT NULL"
date reading_date "NOT NULL"
string note
datetime created_at "NOT NULL"
}
tariff_periods {
int id PK
int meter_id FK "NOT NULL"
enum tariff_kind "NOT NULL"
date valid_from "NOT NULL"
date valid_to
numeric price_per_unit "NOT NULL"
numeric base_fee_monthly
numeric metering_fee_monthly
numeric gas_z_zahl
numeric gas_brennwert
numeric eeg_tier1_rate
numeric eeg_tier2_rate
numeric metering_point_fee_yearly
string comment
datetime created_at "NOT NULL"
}
meters ||..o{ advance_payments : "meters_advance_payments_fk"
meters ||..o{ meters : "meters_meters_fk"
meters ||..o{ readings : "meters_readings_fk"
meters ||..o{ tariff_periods : "meters_tariff_periods_fk"
Note: The ER diagram (Mermaid block above) is auto-generated from the SQLAlchemy models.
Do not edit the diagram manually — model changes are reflected by running:
cd backend && uv sync --extra dev && uv run python ../scripts/generate_erd.py --update-readme.
Meter
id(integer, primary key)meter_type(enum: electricity_import, electricity_export, electricity_production, gas, water_cold, water_garden)label(string, e.g., "Main Electricity Meter")meter_number(string, optional, e.g., "1EMH0012345678")unit(string, e.g., "kWh", "m³") - server-derived frommeter_type, not client-settable: aunitfield in a POST/PUT request body is rejected with 422 Unprocessable Entityreplaces_meter_id(integer, optional, FK → Meter.id) - links to the predecessor meter this one physically replaced;nullfor standalone meterscreated_at(datetime)
Meter replacement chain: A meter with replaces_meter_id set forms a 1:1 replacement chain (e.g. old meter P → new meter M). The cost and projection endpoints follow the chain automatically. Validation rules:
replaces_meter_idmust reference an existing meter of the samemeter_type(422 otherwise)- A meter cannot reference itself (422)
- No hard DB constraint prevents multiple successors (application logic only)
- Changing
meter_typevia PUT is rejected (422) if readings exist for this meter
Reading
id(integer, primary key)meter_id(foreign key to Meter)value(numeric, meter reading value)reading_date(date)note(string, optional)created_at(datetime)- Constraint: Unique (meter_id, reading_date) - prevents duplicate readings on the same date
TariffPeriod
id(integer, primary key)meter_id(foreign key to Meter)tariff_kind(enum: consumption, feed_in, feed_in_eeg2009, self_consumption, wastewater)valid_from(date)valid_to(date, optional, null means "open-ended - valid until today")price_per_unit(numeric)base_fee_monthly(numeric, optional, only for consumption tariff kind)metering_fee_monthly(numeric, optional, only for consumption tariff kind)gas_z_zahl(numeric(10,5), optional, gas m³→kWh conversion factor)gas_brennwert(numeric(10,5), optional, gas m³→kWh conversion factor)eeg_tier1_rate(numeric(10,5), optional, only for feed_in_eeg2009 tariff kind)eeg_tier2_rate(numeric(10,5), optional, only for feed_in_eeg2009 tariff kind)metering_point_fee_yearly(numeric(10,2), optional, only for feed_in_eeg2009 tariff kind)comment(string(500), optional, free text for provider name or notes)created_at(datetime)- Constraint: EXCLUDE USING gist (meter_id WITH =, tariff_kind WITH =, daterange(valid_from, valid_to, '[]') WITH &&)
- Prevents overlapping periods within same (meter_id, tariff_kind)
- Different tariff_kind values may overlap in time (e.g., consumption price and feed-in tariff can be valid simultaneously)
- Both bounds inclusive: adjacent periods must use valid_to = day before next valid_from
AdvancePayment
id(integer, primary key)category(enum: gas, cold_water, wastewater, power_import)meter_id(foreign key to Meter, CASCADE delete, nullable — used for non-wastewater categories)water_cold_meter_id(foreign key to Meter, CASCADE delete, nullable — required for wastewater category)water_garden_meter_id(foreign key to Meter, CASCADE delete, nullable — optional for wastewater category)valid_from(date)valid_to(date, optional, null means "open-ended")monthly_amount(numeric(10,2))comment(string(500), optional, free text for provider name or notes)created_at(datetime)- CHECK constraints:
ck_advance_payment_wastewater:NOT (category = 'wastewater') OR (meter_id IS NULL AND water_cold_meter_id IS NOT NULL)ck_advance_payment_non_wastewater:(category = 'wastewater') OR (meter_id IS NOT NULL AND water_cold_meter_id IS NULL AND water_garden_meter_id IS NULL)
- Partial EXCLUDE constraints (overlap prevention, both bounds inclusive):
excl_advance_payment_non_wastewater: no overlapping periods for the same(meter_id, category)excl_advance_payment_wastewater: no overlapping periods for the samewater_cold_meter_id
Development Commands
Backend
# Run tests
cd backend && uv run --extra dev pytest
# Run linting
cd backend && uv run --extra dev ruff check --fix && uv run --extra dev mypy app/models
# B008 (FastAPI Query/Depends/Body defaults) is ignored per-file in app/api/*.py
# Create migration
cd backend && uv run alembic revision --autogenerate -m "description"
# Apply migrations
cd backend && uv run alembic upgrade head
Frontend
# Run development server
cd frontend && npm run dev
# Build for production
cd frontend && npm run build
# Run linting
cd frontend && npm run lint
# Run linting and auto-fix what is safely auto-fixable
cd frontend && npm run lint:fix
# Run tests
cd frontend && npm run test
# Run tests in watch mode
cd frontend && npm run test:watch
Note on ESLint Status: The four strict rules from eslint-plugin-react-hooks@7.1.1 that reported 18 errors after the September 2026 toolchain upgrade (react-hooks/set-state-in-effect, react-hooks/immutability, react-hooks/refs, react-hooks/purity) are resolved via the React 19 upgrade + useFetch migration (Sept 22, 2026): 0 errors, 38 warnings (29 no-explicit-any, 8 no-console, 1 react-refresh – pre-existing, out of scope). npm run lint exits non-zero only because of the --max-warnings 0 flag. Details: frontend/README.md section 11.
Project Structure
home-energy-usage-tracker/
├── backend/
│ ├── app/
│ │ ├── api/
│ │ │ ├── __init__.py # Router registration
│ │ │ ├── advance_payments.py # CRUD endpoints for advance payments
│ │ │ ├── costs.py # Cost, wastewater, yearly-projection, consumption-timeseries,
│ │ │ │ # and advance-payment-diff endpoints (helpers live in services/)
│ │ │ ├── deps.py # Dependency injection (database session)
│ │ │ ├── electricity.py # EEG 2009 feed-in compensation endpoint
│ │ │ ├── meters.py # CRUD endpoints for meters
│ │ │ ├── projections.py # Projection endpoints for annual consumption
│ │ │ ├── readings.py # CRUD endpoints for readings
│ │ │ └── tariff_periods.py # CRUD endpoints for tariff periods
│ │ ├── models/
│ │ │ ├── __init__.py # Exports all SQLAlchemy ORM models
│ │ │ ├── advance_payment.py # AdvancePayment ORM model
│ │ │ ├── base.py # Base model with id and timestamp mixin
│ │ │ ├── meter.py # Meter ORM model with MeterType enum
│ │ │ ├── reading.py # Reading ORM model
│ │ │ └── tariff_period.py # TariffPeriod ORM model with TariffKind enum
│ │ ├── schemas/
│ │ │ ├── __init__.py # Exports all Pydantic schemas
│ │ │ ├── advance_payment.py # AdvancePayment schemas (Create, Update, Response)
│ │ │ ├── cost.py # Cost, wastewater, advance-payment-diff, and consumption-timeseries schemas
│ │ │ ├── meter.py # Meter schemas (Create, Update, Response)
│ │ │ ├── projection.py # Projection response schema
│ │ │ ├── reading.py # Reading schemas
│ │ │ ├── tariff_period.py # TariffPeriod schemas with TariffKind
│ │ │ └── yearly_projection_cost.py # Yearly projection with cost response schema
│ │ ├── services/
│ │ │ ├── cost_calculation.py # Pure cost math (proration, multi-period rounding, gas conversion)
│ │ │ ├── cost_queries.py # DB lookup / validation helpers for cost endpoints
│ │ │ ├── meter_chain.py # Meter replacement chain resolution
│ │ │ ├── meter_chain_costs.py # Replacement-chain consumption & cost logic
│ │ │ └── projection.py # Business logic for projection calculations
│ │ ├── __init__.py
│ │ ├── config.py # Configuration with pydantic-settings
│ │ ├── database.py # SQLAlchemy engine and session factory
│ │ └── main.py # FastAPI application with middleware
│ ├── alembic/ # Database migrations
│ ├── tests/ # Backend tests
│ ├── pyproject.toml # Python dependencies (uv)
│ ├── uv.lock # Locked dependencies
│ ├── .dockerignore
│ ├── .ruff_cache/ # Linting cache
│ ├── .mypy_cache/ # Type checking cache
│ └── Dockerfile # Production backend image
├── frontend/
│ ├── src/
│ │ ├── api/ # API client
│ │ ├── components/ # React components (EditMeterModal, ThemeToggle)
│ │ ├── components/ui/ # shadcn/ui components
│ │ ├── components/year-detail/ # Year detail page components (MeterBlock, ElectricitySection, WaterSection, GasSection, ReadingEditDialog)
│ │ ├── components/shared/ # Shared chart/formatting helpers (chartHelpers.ts, ChartTooltip.tsx)
│ │ ├── components/custom-range/ # Custom range page components (AreaBlock)
│ │ ├── pages/ # Page components
│ │ ├── types/ # TypeScript interfaces
│ │ ├── hooks/ # Custom React hooks
│ │ ├── lib/ # Utility functions
│ │ ├── App.tsx # Main application
│ │ └── main.tsx # React entry point
│ ├── dist/ # Production build output
│ ├── node_modules/ # Node.js dependencies
│ ├── nginx.conf # Nginx configuration (production)
│ ├── Dockerfile # Production frontend image (nginx)
│ ├── Dockerfile.dev # Development frontend image (Vite)
│ ├── package.json # Node.js dependencies
│ ├── package-lock.json # Locked dependencies
│ ├── tailwind.config.js # Tailwind CSS configuration
│ ├── postcss.config.js # PostCSS configuration
│ └── vite.config.ts # Vite configuration
├── docker-compose.yml # Production Docker setup (3 services)
├── docker-compose.dev.yml # Development Docker setup (4 services with test DB)
├── .env.example # Environment variables template
├── AGENTS.md # Agent instructions for this project
└── README.md # This file
Security Note
This application is designed for local, single-user operation only. It does not include:
- User authentication
- Role-based access control
- HTTPS (in local deployment)
- Rate limiting
- Input sanitization beyond basic validation
For production use with multiple users or internet exposure, additional security measures are required.
Troubleshooting
Environment Variable Issues
If services fail to connect or use incorrect database URLs:
- Check
.envfile exists: Make sure.env.examplewas copied to.env - Verify Docker Compose substitution:
- Production:
docker compose configshows resolved environment variables - Development:
docker compose -f docker-compose.dev.yml config
- Production:
- Database connection issues:
- Docker containers: URLs use
dbhostname (e.g.,postgresql://postgres:postgres@db:5432/energy_tracker) - Native development: URLs use
localhosthostname (matches.envfile) - Ensure
DATABASE_HOSTislocalhostfor native dev,dbfor Docker
- Docker containers: URLs use
Docker Container Hangs on Stop
If containers hang during shutdown with SIGTERM failed to stop container ... resorting to SIGKILL:
- Fixed: Backend uses
execto make uvicorn PID 1 for clean SIGTERM handling - Fixed: Frontend uses
execfor nginx (production) and npm (development) - Check: Both
docker-compose.ymlanddocker-compose.dev.ymluseexecin backend command - Check: Dockerfiles use
execin CMD for frontend services - Works with: Production (nginx proxy) and Development (hot-reload) setups
- Containers now stop within Docker/Podman timeout (usually 10s)
Database Migration Issues
If you encounter type "metertype" already exists errors:
- The migration creates the PostgreSQL enum type automatically with the
meterstable - If you see duplicate type creation errors, check that the migration doesn't create the enum both explicitly and implicitly
- To reset:
docker compose down --volumes(⚠️ deletes all data)
External Network Not Found
The Prod Stack references the pre-existing Docker network energy-net. On a fresh machine you get network energy-net was found but has incorrect label or network energy-net not found:
# Create the network once (idempotent — rerunning reports it already exists)
docker network create energy-net
Container/Engine-Specific Issues
This project works with both Docker and Podman.
- Containers may be managed via systemd:
systemctl --user status podman-compose@home-energy-usage-tracker.service - If containers hang, check the logs:
docker logs <container_name> - For rootful Podman, you may need
sudofor socket access - On Podman,
DOCKER_HOSTmust point at the Podman socket, e.g.DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
API Endpoints Return 500 with Enum Errors
If POST requests fail with invalid input value for enum metertype: "ELECTRICITY":
- SQLAlchemy persists enum member names by default (e.g.,
ELECTRICITY) - The database stores values (
electricity) - Fix: Add
values_callable=lambda e: [m.value for m in e]to SQLAlchemy Enum columns
Frontend Build Issues
If TypeScript errors appear during frontend build:
- Ensure
frontend/src/vite-env.d.tsexists with/// <reference types="vite/client" /> - Check for missing dependencies:
cd frontend && npm install
If docker compose build (dev or prod) fails at RUN npm ci with ERESOLVE could not resolve peer react@...:
npm ciin the container runs with strict peer-dependency resolution — a package whose peer range excludes the installed React version breaks the build (e.g.lucide-react@0.303only allowed React ≤18)- Local
npm install --legacy-peer-depsmasks this error — after any React major upgrade, runnpm ciin a scratch copy ofpackage.json+package-lock.jsonbefore building containers - Fix: upgrade the offending dependency (lucide-react ≥1.x declares React 19 as peer), not by adding
--legacy-peer-depsto the Dockerfile - Also check
package.jsonandpackage-lock.jsonare in sync (npm cirefuses an out-of-sync lock)
Backup & Restore
Data Backup
To export your energy tracking data (schema is created via Alembic on the target system):
# Find your database container name
docker ps | grep postgres
# Export data only (schema comes from Alembic on the target system)
docker exec <db-container> pg_dump -U postgres -d energy_tracker \
--data-only --column-inserts > energy_data_backup.sql
Data Restore
To import data on the target system (after alembic upgrade head has run there):
# Find your database container name on the target system
docker ps | grep postgres
# Import the backup
cat energy_data_backup.sql | docker exec -i <db-container> \
psql -U postgres -d energy_tracker
Note: This exports only data, not schema. The schema must be created separately via Alembic migrations (alembic upgrade head).
License
MIT License
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for setup, commands, and the expected workflow. This project follows the Contributor Covenant Code of Conduct.
For AI agents
Repository rules for coding agents live in AGENTS.md (root) plus the
nested files backend/AGENTS.md and frontend/AGENTS.md.