PLAN MODE: Use Plan Mode frequently! Before implementing complex features, multi-step tasks, or making significant changes, switch to Plan Mode to think through the approach, consider edge cases, and outline the implementation strategy.
IMPORTANT: Do NOT update this file unless the user explicitly says to. Only the user can authorize changes to AGENTS.md.
SECURITY WARNING: This repository is PUBLIC at github.com/GeiserX/pumperly. NEVER commit secrets, API keys, passwords, tokens, or any sensitive data. All secrets must be stored in:
- GitHub Secrets (for CI/CD)
- Private GitOps repositories (for docker-compose)
- Local
.envfiles (gitignored)
Pumperly is an open-source, self-hostable web application that combines real-time energy price comparison with intelligent route planning — for both fuel and electric vehicles. It answers the question no other app in the world currently answers: "What's the cheapest place to refuel or recharge along my route, and is the detour worth it?"
- Live URL: https://pumperly.com
- Repository: https://github.com/GeiserX/pumperly
- License: GPL-3.0
No product worldwide combines all three capabilities:
- Full route planning (A to B with waypoints)
- Real-time energy price filtering along the route (fuel + EV charging)
- Detour time/cost calculation for every station on your route
The closest analog is A Better Route Planner (ABRP) for EVs — Pumperly does this for ALL vehicle types, with real-time pricing.
Operator: Sergio Fernandez Rubio Trade Name: GeiserCloud GitHub: GeiserX
- Be direct and efficient — don't over-explain
- Do the work, don't ask permission for clear tasks
- Wait for explicit deploy instruction — do NOT commit or deploy until told
- Use exact values when provided
- Clean, readable code without over-engineering
- Self-hosted solutions over SaaS
- Privacy-focused (cookieless analytics, minimal data collection)
- Semver versioning for Docker images (never
:latest) - GitOps with Portainer for infrastructure
- Docker Hub for images (
drumsergio/pumperly) - Tailwind CSS for styling
- TypeScript strict mode
- Do NOT add Co-Authored-By lines to commits
- Do NOT add "Generated with Claude Code" attribution anywhere
| Technology | Purpose |
|---|---|
| Next.js 15+ | App Router, Server Components |
| React 19 | UI library |
| TypeScript | Type safety (strict mode) |
| Tailwind CSS | Styling (custom components, no component library) |
| MapLibre GL JS | GPU-accelerated vector tile map rendering |
| react-map-gl | React wrapper for MapLibre (react-map-gl/maplibre) |
| Custom i18n | src/lib/i18n.tsx — React Context + inline translations, 16 locales |
| Technology | Purpose |
|---|---|
| Next.js API Routes | REST API endpoints |
| Prisma ORM | Database access with PostGIS extensions |
| Zod | Request/response validation |
| Component | Details |
|---|---|
| PostGIS 17 | Spatial database for stations + prices (postgis/postgis:17-3.4) |
| Valhalla 3.5.1 | Self-hosted routing engine (ghcr.io/gis-ops/docker-valhalla/valhalla:3.5.1). 31-country merged PBF (~25GB) built with osmium-tool. Multiple PBFs cause SIGABRT — always merge first. Needs 24GB RAM limit for tile build. Tile count TBD after rebuild. |
| Protomaps PMTiles | Self-hosted vector map tiles on NVMe |
| OpenFreeMap | Primary tile provider (free, no API key, no rate limits) |
| Photon 1.0.1 | Geocoding / address autocomplete. Runs on eclipse-temurin:21-jre with official JAR. Uses OpenSearch backend (NOT old Elasticsearch). Data imported from per-country JSONL dumps (31 regions covering 32+ countries, ~132.7M documents). Single-pass concatenated import: all dumps downloaded in parallel, decompressed and concatenated into one file, then imported in a single java -jar photon.jar import invocation. |
| Caddy | Reverse proxy (existing on watchtower) |
| Docker | Multi-stage builds, images on Docker Hub |
| Portainer | Container management with GitOps |
Government / Official APIs (9 countries):
| Country | Source | Update Freq | Stations | Auth | Scraper Status |
|---|---|---|---|---|---|
| Spain | MITECO REST API | Daily | ~12,215 | None | ✅ Running |
| France | data.economie.gouv.fr bulk export | 10 min | ~9,868 | None | ✅ Running |
| Portugal | DGEG paginated API | Daily | ~3,186 | None (non-commercial) | ✅ Running |
| Italy | MIMIT CSV (pipe-delimited) | Daily | ~23,605 | None | ✅ Running |
| Austria | E-Control API (per-district) | Real-time | ~920 | None | ✅ Running |
| Germany | Tankerkoenig v4 API | Real-time | ~14,347 | Free API key | ✅ Running |
| UK | CMA Open Data (13 retailer JSON feeds) | Near real-time | ~3,536 | None | ✅ Running |
| Slovenia | goriva.si REST API | Real-time | ~551 | None | ✅ Running |
| Denmark | fuelprices.dk API | Real-time | ~3,000+ | Free API key | ✅ Running |
Community / Commercial APIs (22 countries):
Mix of Fuelo.net scrapers (src/scrapers/fuelo.ts), dedicated scrapers (ANWB, Peco Online, FuelGR, etc.), and other sources.
| Country | Source | Currency | Stations | Scraper Status |
|---|---|---|---|---|
| Netherlands | ANWB | EUR | ~4,154 | ✅ Running |
| Belgium | ANWB | EUR | ~3,000+ | ✅ Running |
| Luxembourg | ANWB | EUR | ~200+ | ✅ Running |
| Romania | Peco Online | RON | ~3,000+ | ✅ Running |
| Greece | FuelGR | EUR | ~6,000+ | ✅ Running |
| Ireland | Pick A Pump | EUR | ~1,500+ | ✅ Running |
| Croatia | MZOE | EUR | ~800+ | ✅ Running |
| Switzerland | Fuelo.net | CHF | ~3,000+ | ✅ Running |
| Poland | Fuelo.net | PLN | ~7,000+ | ✅ Running |
| Czech Republic | Fuelo.net | CZK | ~4,000+ | ✅ Running |
| Hungary | Fuelo.net | HUF | ~2,000+ | ✅ Running |
| Bulgaria | Fuelo.net | BGN | ~3,000+ | ✅ Running |
| Slovakia | Fuelo.net | EUR | ~1,500+ | ✅ Running |
| Sweden | bensinpriser.nu | SEK | ~3,000+ | ✅ Running |
| Norway | DrivstoffAppen | NOK | ~2,000+ | ✅ Running |
| Serbia | NIS / cenagoriva | RSD | ~2,000+ | ✅ Running |
| Finland | polttoaine.net | EUR | ~2,000+ | ✅ Running |
| Estonia | Fuelo.net | EUR | ~522 | ✅ Running |
| Latvia | Fuelo.net | EUR | ~809 | ✅ Running |
| Lithuania | Fuelo.net | EUR | ~854 | ✅ Running |
| Bosnia & Herzegovina | Fuelo.net | BAM | ~436 | ✅ Running |
| North Macedonia | Fuelo.net | MKD | ~353 | ✅ Running |
Non-European (5 countries):
| Country | Source | Currency | Stations | Scraper Status |
|---|---|---|---|---|
| Turkey | Fuelo.net | TRY | ~5,000+ | ✅ Running |
| Moldova | ANRE | MDL | ~300+ | ✅ Running |
| Australia (WA + NSW) | FuelWatch / FuelCheck | AUD | ~4,000+ | ✅ Running |
| Argentina | Secretaría de Energía | ARS | ~4,600+ | ✅ Running |
| Mexico | CRE | MXN | ~13,500+ | ✅ Running |
Total: 36 countries, ~145K+ stations
Fuelo Dependency Note: Research (Mar 2026) confirmed no viable government API alternatives exist for most Fuelo countries. Only DE, ES, AT, FR, DK, IT, GB, PT, SI have true government/official APIs. The EU AFIR Delegated Regulation 2024/1557 should eventually require station-level price data from EU member states, but implementation is incomplete as of 2026. Bosnia's FMT EOPC API (fmteopc.azurewebsites.net) has full station-level data but requires authentication.
pumperly.com (Caddy reverse proxy on watchtower)
|
+-- Next.js App (SSR + API routes) [Port 3200, ~512MB RAM]
| |
| +-- PostGIS (stations + prices) [Port 5433, ~2-4GB RAM]
| |
| +-- Valhalla (routing engine) [Port 8002, ~2-4GB RAM]
| |
| +-- PMTiles (static on NVMe via Caddy)
|
+-- Scraper workers (built into app, per-country intervals) [~256MB RAM]
|
+-- Photon (geocoding) [Port 2322, ~1GB RAM]
Steady-state: ~8-10GB RAM, ~60GB disk (36 countries). First-time build: needs ~24GB RAM limit (Valhalla tile build from ~25GB merged PBF). Run Valhalla and Photon builds sequentially to avoid memory pressure. Disk breakdown: Merged PBF ~25GB, Valhalla tiles ~15-20GB, Photon data ~20-25GB (31-region index, 132.7M docs), PostGIS ~3GB (~120K stations), app ~50MB.
stations
- id (UUID, PK)
- external_id (VARCHAR, unique per country source)
- country (VARCHAR, ISO 3166-1 alpha-2)
- name (VARCHAR)
- brand (VARCHAR, nullable)
- address (TEXT)
- city (VARCHAR)
- province (VARCHAR, nullable)
- geom (GEOMETRY(Point, 4326), GiST indexed)
- station_type (VARCHAR: 'fuel' | 'ev_charger' | 'both')
- opening_hours (JSONB, nullable)
- amenities (JSONB, nullable)
- created_at (TIMESTAMPTZ)
- updated_at (TIMESTAMPTZ)
fuel_prices
- id (BIGINT, PK, auto)
- station_id (UUID, FK -> stations.id)
- fuel_type (VARCHAR, EU harmonized: E5, E10, B7, B10, LPG, CNG, H2, etc.)
- price (DECIMAL(6,3), per liter in local currency)
- currency (VARCHAR(3), ISO 4217: EUR, GBP, PLN, etc.)
- reported_at (TIMESTAMPTZ)
- source (VARCHAR: miteco, tankerkoenig, opendatasoft, etc.)
- INDEX (station_id, fuel_type, reported_at DESC)
ev_chargers (future — Phase 5+)
- id (UUID, PK)
- station_id (UUID, FK -> stations.id)
- connector_type (VARCHAR: CCS2, CHAdeMO, Type2, etc.)
- power_kw (DECIMAL)
- price_per_kwh (DECIMAL, nullable)
- network (VARCHAR: Tesla, Ionity, ChargePoint, etc.)
- available (BOOLEAN, nullable)
price_history
- Same as fuel_prices but partitioned by month for analytics
- Populated by trigger on fuel_prices INSERT
Internal canonical IDs — display localized names per country/language:
| Code | Description | Spain | France | Germany | Italy | UK |
|---|---|---|---|---|---|---|
| E5 | Gasoline <=5% ethanol | Gasolina 95 E5 | SP95 | Super E5 | Benzina | Unleaded (E5) |
| E10 | Gasoline <=10% ethanol | Gasolina 95 E10 | SP95-E10 | Super E10 | Benzina E10 | Unleaded (E10) |
| E5_98 | Gasoline 98 oct | Gasolina 98 E5 | SP98 | Super Plus | Benzina 98 | Super Unleaded |
| B7 | Diesel <=7% biodiesel | Gasoleo A | Gazole | Diesel | Gasolio | Diesel |
| B7_PREMIUM | Premium diesel | Gasoleo Premium | Gazole Premium | Diesel Premium | Gasolio Premium | Premium Diesel |
| LPG | Autogas | GLP | GPLc | Autogas | GPL | LPG |
| CNG | Compressed natural gas | GNC | GNV | CNG/Erdgas | Metano | CNG |
| H2 | Hydrogen | Hidrogeno | Hydrogene | Wasserstoff | Idrogeno | Hydrogen |
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
PostGIS connection string | Required |
PUMPERLY_DEFAULT_COUNTRY |
ISO code for initial map view | ES |
PUMPERLY_ENABLED_COUNTRIES |
Comma-separated ISO codes to enable (e.g. ES,FR,DE) |
All countries with scrapers |
PUMPERLY_DEFAULT_FUEL |
Override default fuel type | Per-country default |
PUMPERLY_CLUSTER_STATIONS |
Enable station clustering at low zoom (true/false) |
true |
PUMPERLY_CORRIDOR_KM |
REMOVED — now a dynamic UI slider (1-25km, default 5km) | — |
PUMPERLY_SCRAPE_INTERVAL_HOURS |
Global scrape interval override (0 = disabled) | Per-country defaults |
PUMPERLY_SCRAPE_INTERVAL_XX |
Per-country interval, e.g. PUMPERLY_SCRAPE_INTERVAL_FR=0.5 |
See defaults below |
TANKERKOENIG_API_KEY |
Germany Tankerkoenig API key (free registration) | — |
VALHALLA_URL |
Valhalla routing engine URL | — |
PHOTON_URL |
Photon geocoding service URL | — |
Per-country default scrape intervals: ES=12h, FR=1h, PT=12h, IT=12h, AT=2h, DE=1h, GB=4h, SI=6h, DK=6h, all Fuelo countries=12h. Real-time sources (FR/DE/AT) scrape more frequently; daily sources (ES/PT/IT) scrape every 12h. Each country runs on its own timer with staggered startup (5s apart).
These env vars allow self-hosters to scope the app to their country/region. For example, a French self-hoster can set PUMPERLY_DEFAULT_COUNTRY=FR and PUMPERLY_ENABLED_COUNTRIES=FR to show only France.
- No timezone-based country detection — use env vars for country config, not client TZ
- Navbar is dark (
#0c111b) — minimal height (44px), Pumperly logo on left, fuel selector on right - Logo: Emerald-to-cyan gradient rounded square with lightning bolt cutout. "Pumperly" wordmark in bold white
- Stats dropdown in navbar (right side, icon buttons) — shows station/price totals, per-country breakdown with flags, last update timestamp. Footer: "Made with ♥ by Sergio Fernández" + GitHub Sponsors button
- Navbar right-side buttons: geolocate, theme toggle (dark/light), stats, settings gear (mobile). All icon buttons with
border-white/[0.08]styling matching the navbar - Dark mode: ThemeProvider in
src/lib/theme.tsx. Uses Tailwind@custom-variant darkwith class strategy. Map switches between OpenFreeMapliberty(light) anddarkstyles. Persists to localStorage, defaults to system preference - Fuel selector has optgroup categories (Diésel, Gasolina, Gas, Hidrógeno, Otros) with category icon
- Station popup layout (top to bottom): brand name (bold, primary heading) → address + city (small gray) → price card (large 22px price + EUR/L, fuel type label + "· Actualizado hace Xh" below). "Sin precio para [fuel]" if no data. Brand comes from MITECO "Rótulo" field;
name= brand + city (internal, not shown in popup) - Map clustering: Controlled by
PUMPERLY_CLUSTER_STATIONSenv var. When enabled, clusters only at zoom ≤7, radius 40px. Production instance has it disabled. 12K stations renders fine in MapLibre without clustering. - Map default center/zoom comes from server config (env vars), not hardcoded
- Auto-geolocation on load: Uses Permissions API to check state first — if
granted, flies directly (no wasted default fetch); ifdenied, loads default country view; ifprompt, loads default view then asks (re-fetches if accepted). This avoids double fetches for returning users. - Station fetch: No min-zoom gate — stations load at all zoom levels (API returns 12K stations in ~100ms). 100ms debounce on pan/zoom.
- Search panel: Always expanded by default. Users can collapse and re-expand.
- Fuel dropdown: Uses explicit dark backgrounds on
<option>elements to prevent unreadable text on light OS themes. - Route z-order: Route line renders below station points (
beforeId="unclustered-point") so stations are always clickable. - Docker Publish workflow: Must include a
type=raw,value=latest,enable={{is_default_branch}}tag rule — without it, main-branch pushes produce zero tags and the build fails.
src/lib/photon.ts— Photon geocoding client. Calls/apiwith query, language, optional geo-bias.src/lib/valhalla.ts— Valhalla routing client. CallsPOST /routewith locations + costing. Includes precision-6 polyline decoder.src/app/api/geocode/route.ts—GET /api/geocode?q=Madrid&lat=40.4&lon=-3.7— Zod validated, proxies to Photon.src/app/api/route/route.ts—POST /api/routewith{ origin, destination, waypoints? }— calls Valhalla.src/app/api/route-stations/route.ts—POST /api/route-stationswith{ geometry, fuel, corridorKm? }— finds stations within N km of route usingST_DWithinon PostGIS geography.src/components/search/search-panel.tsx— Left-side collapsible panel (Google Maps style). Origin + destination inputs, swap, "Calcular ruta" button. Auto-geocodes typed text on submit if no autocomplete selection was made.src/components/search/autocomplete-input.tsx— Reusable autocomplete with 300ms debounce, keyboard nav, geo-biased results. Exposesgeocode()method viaforwardRef.src/components/map/route-layer.tsx— MapLibre route visualization: white outline (7px) + blue fill (4px,#3b82f6).src/components/map/map-view.tsx— UsesforwardRefto expose MapRef. Switches between bbox station fetch and corridor station fetch based on active route.
- Valhalla: 31 European countries merged PBF (~25GB, merged with
osmium merge). Image:ghcr.io/gis-ops/docker-valhalla/valhalla:3.5.1. Needs 24GB RAM limit. Notile_urlsenv var — uses local PBF in/custom_files/. CRITICAL: Valhalla crashes (SIGABRT) in two known scenarios: (1) building from multiple PBFs (vector::_M_range_check) — always merge with osmium first, (2) malformed OSMleveltags (level=*,level=LG) onhighway=corridorways causedouble free or corruption (fasttop)— crash-loops every ~4h at the same build point. Fix: filter corridors before building:osmium tags-filter -i input.pbf w/highway=corridor -o output.pbf(do NOT use-Rflag — it drops referenced nodes and shrinks the file to 9GB). — always merge with osmium first. To add a country: download new PBF from Geofabrik, re-merge all PBFs, deletevalhalla_tiles.tar+file_hashes.txt+admin_data/+timezone_data/, restart. Also delete old PBF before restart (Valhalla auto-discovers ALL.pbffiles in/custom_files/and tries to build from all of them). Warmup: After container restart, Valhalla needs time to load tiles into memory. Short routes work quickly; cross-continent routes may fail for ~30-60s after start. Steady-state ~2GB RAM. Osmium merge on watchtower:docker run --rm -v /mnt/user/appdata/pumperly/valhalla:/data stefda/osmium-tool osmium merge /data/*.osm.pbf -o /data/merged.osm.pbf(osmium 1.7.1, uses ~4.4GB RAM for 25GB merge). - Photon: No official Docker image. Uses
eclipse-temurin:21-jre(do NOT use JRE 25 — causes issues) with custom entrypoint. Downloads Photon 1.0.1 JAR + per-country JSONL dumps fromdownload1.graphhopper.com/public/europe/{country}/photon-dump-{country}-1.0-latest.jsonl.zst. Single-pass concatenated import: All 31 country dumps are downloaded in parallel, verified, decompressed and concatenated into a singleall.jsonlfile, then imported in ONEjava -jar photon.jar importinvocation. Uses.import_completesentinel file — if present, skips straight to serving. Must bind to0.0.0.0(-listen-ip 0.0.0.0). To add a country: add it to the COUNTRIES list in the compose entrypoint, increment EXPECTED count, delete.import_complete+photon_data/, restart. 12GB container memory limit, 4GB Java heap (-Xmx4g). - Photon regions vs scraper countries: 31 Photon geocoding regions cover 32+ European countries. Some regions bundle multiple countries (e.g.
british-islands= UK+IE,france-monacco= FR+MC,baltics= EE+LV+LT,switzerland-liechtenstein= CH+LI). All 31 active European scraper countries have geocoding coverage. Non-European countries (AR, AU, MX) would need Photon dumps from other continents. - CRITICAL Photon lessons learned:
- (1) Sequential per-country imports FAIL — after the first import creates a 5-shard OpenSearch index, subsequent imports start new OpenSearch instances that must recover existing shards. Shards 3-4 get
deciders_throttled,ensureYellowtimes out, container crash-loops. Always use single concatenated import. - (2) FIFO pipes silently lose data —
mkfifoapproach seems elegant but the feeder subshell dies from SIGPIPE when Java closes the reader. Java processes what it has and exits 0. No error propagation. Always use real files on disk. - (3) OpenSearch node locking — Only ONE OpenSearch instance can access the data directory at a time. Running
importin background whileserveruns causesfailed to obtain node locks. Import must complete before serve starts. - (4) Country-specific dumps are the reliable approach — Do NOT use planet dump with
-country-codesflag (NPE on entries lackingcountry_code). Do NOT usegrep/awkto filter (strips JSONL header). - (5) Graphhopper country naming quirks:
france-monacco(sic),switzerland-liechtenstein(not justswitzerland),baltics(EE+LV+LT combined, not individual),british-islands(UK+IE),luxemburg(notluxembourg). - (6) Docker Compose
$$escaping required for shell variables in the command block. - (7) Old
lehrenfried/photonimage is incompatible (Elasticsearch 5.5.0 vs OpenSearch). - (8)
waitwithout args only returns last child's exit code — track individual PIDs for parallel downloads to catch failures. - (9) Verification must check country codes —
curl "api?q=Tallinn"returns fuzzy French matches (Tallans, Tallone). Always verify withcountrycodefield matching expected ISO code.
- (1) Sequential per-country imports FAIL — after the first import creates a 5-shard OpenSearch index, subsequent imports start new OpenSearch instances that must recover existing shards. Shards 3-4 get
See ROADMAP.md for the full feature breakdown and phase plan.
- React Context-based
I18nProviderwithuseI18n()hook returningt(key)translator - All translations inline in TypeScript (no external JSON files)
- URL routing:
[locale]dynamic route prefix (/es/,/fr/,/de/) - Middleware detects locale from URL → cookie →
Accept-Languageheader - Default locale:
es(Spanish) - 16 supported locales:
es,en,fr,de,it,pt,pl,cs,hu,bg,sk,da,sv,no,sr,fi
- Browser language detection via
navigator.languages - Persisted to localStorage + cookie (for middleware on next load)
- Manual override via language picker in navbar
src/lib/currency.tsx— CurrencyProvider context with 31 ECB-supported currenciessrc/app/api/exchange-rates/route.ts— Proxies ECB daily XML feed, in-memory cache (24h TTL), serves stale on ECB failure- Auto-detection: Uses
navigator.languages→ region code → currency (standard approach like Booking.com/Google) - Display: Native prices shown as-is; converted prices prefixed with
≈and show original price + ECB rate + date in popup - Architecture:
useConvertedStations()hook pre-converts GeoJSON station prices so MapLibre color scale, price filter slider, and all UI components work in the selected currency. Original price/currency preserved inoriginalPrice/originalCurrencyproperties. - Decimal places: Currency-aware (3 for EUR/GBP/USD, 2 for SEK/NOK/CZK, 0 for JPY/KRW/HUF/IDR etc.)
- Supported: EUR + 30 ECB-published currencies (USD, GBP, CHF, JPY, SEK, NOK, DKK, ISK, CZK, PLN, HUF, RON, BGN, TRY, CNY, HKD, KRW, SGD, MYR, THB, IDR, PHP, INR, ILS, ZAR, BRL, MXN, CAD, AUD, NZD)
Each country has a dedicated scraper module in src/scrapers/. Scrapers run as cron jobs (Docker containers).
interface Scraper {
country: string; // ISO 3166-1 alpha-2
source: string; // e.g., 'miteco', 'tankerkoenig'
schedule: string; // cron expression
fetchStations(): Promise<Station[]>;
fetchPrices(): Promise<FuelPrice[]>;
}Spain (MITECO) — Base: https://sedeaplicaciones.minetur.gob.es/ServiciosRESTCarburantes/PreciosCarburantes/. EstacionesTerrestres/ returns all stations + prices in a single request. No auth. Cloud IPs sometimes blocked. Default scrape interval: 12h.
France (Opendatasoft) — Uses bulk /exports/json endpoint (single request, ~9,800 records). Much faster than paginated /records (3s vs 30s). Updates every 10 min. Default scrape interval: 1h.
Germany (Tankerkoenig v4) — Base: https://creativecommons.tankerkoenig.de/api/v4/stations/search. Free API key required (register at onboarding.tankerkoenig.de). 25km radius search limit — covers Germany with ~270 overlapping grid queries (0.40° lat × 0.55° lon steps). Rate limit returns HTTP 503. Default scrape interval: 1h.
Italy (MIMIT) — CSV downloads (pipe-delimited). Coordinates voluntary (~23,600 stations). Default scrape interval: 12h.
UK (CMA Feeds) — 13 retailer JSON endpoints (Shell excluded — returns HTML). All share schema {last_updated, stations: [{site_id, brand, address, postcode, location, prices}]}. Prices in GBP pence. Morrisons stores lat/lng as strings (needs parseFloat). Sentinel values ≥900 or ≤1 pence are filtered. Default scrape interval: 4h.
Austria (E-Control) — Base: https://api.e-control.at/sprit/1.0/. Real-time. API returns max 10 results per query — queries 117 political districts (Bezirke, type=PB) instead of 9 states to get full coverage (~920 stations). Uses includeClosed=false to exclude closed stations with no prices. Default scrape interval: 2h.
Portugal (DGEG) — Base: https://precoscombustiveis.dgeg.gov.pt/api/PrecoComb/. Paginated per fuel type (~3,186 stations). Commercial use prohibited. Default scrape interval: 12h.
Slovenia (goriva.si) — Base: https://goriva.si/api/v1/search/. Paginated, single 200km-radius query from center covers entire country. No auth. 9 fuel types including HVO, CNG, LNG. Government-regulated prices. Default scrape interval: 6h.
pumperly/
├── .github/workflows/
├── prisma/schema.prisma
├── src/
│ ├── app/
│ │ ├── [locale]/page.tsx # Map view (default)
│ │ └── api/{stations,route,route-detour,route-stations,geocode,exchange-rates,config,stats}/
│ ├── components/{map,search,nav}/
│ ├── lib/{db,valhalla,photon,currency,i18n,theme}.ts
│ ├── scrapers/{base,cli,spain,france,germany,italy,uk,austria,portugal,slovenia,...}.ts
│ └── types/station.ts
├── docker/Dockerfile
├── AGENTS.md
├── ROADMAP.md
├── README.md
└── package.json
| Environment | URL | Server |
|---|---|---|
| Production | pumperly.com | watchtower |
| Development | localhost:3000 | Mac |
| Service | Image | RAM | Port | Notes |
|---|---|---|---|---|
| app | drumsergio/pumperly:x.y.z |
512 MB | 3200 | Next.js app |
| db | postgis/postgis:17-3.4 |
2 GB | 5432 | PostGIS spatial DB |
| valhalla | ghcr.io/gis-ops/docker-valhalla/valhalla:3.5.1 |
24 GB (build), ~2 GB (serve) | 8002 | Uses local ~25GB merged PBF (31 European countries). No tile_urls. First build ~3-6 hours. Steady-state ~2GB. Non-European countries (AR, AU, MX) would need separate PBFs added. |
| photon | eclipse-temurin:21-jre |
12 GB limit, 4GB heap (import+serve) | 2322 | Single-pass: parallel download of 31 country dumps → concatenate → single import. Uses .import_complete sentinel. Full import ~12-20 hours. 132.7M documents. Steady-state ~4-6GB. |
| scraper | Built into the app via instrumentation.ts |
— | — | Runs on startup + PUMPERLY_SCRAPE_INTERVAL_HOURS interval. No separate container needed. |
- Never build Docker images locally for deployment — always let GitHub Actions runners build and push (they're amd64, matching production)
- Local
docker buildx --platform linux/amd64is only for emergency hotfixes - GitHub Actions handles: lint, typecheck, Docker build+push, releases, CodeQL
- Docker Hub secrets (
DOCKERHUB_USERNAME,DOCKERHUB_TOKEN) are configured in GitHub repo settings - Deployment flow: commit → push → wait for GitHub Actions Docker Publish workflow to finish → then
docker pulland redeploy on watchtower. Never pull before the workflow completes - Never restart Caddy — always use
caddy reload(Unraid FUSE causes stale file handles on restart)
- Portainer stack: ID 225 ("pumperly"), endpoint 2 on watchtower. Auto-update every 5 minutes from Gitea. Config path:
pumperly/docker-compose.yml. The Gitea repo (giteaer/watchtower) is private — manual redeploy via API may fail with auth errors; auto-update handles it. - Data volumes (all under
/mnt/user/appdata/pumperly/):pgdata/(~600 MB) — PostGIS database. Quick to rebuild via scrapers.valhalla/(~60+ GB) — Pre-built routing tiles + source PBF (~25GB, 31 European countries). Self-healing: Valhalla rebuilds tiles from PBF on start if missing. Rebuild takes 3-6 hours.photon/— OpenSearch index + JAR. Most expensive to rebuild (12-20 hours for 31 regions, 132.7M docs). Uses.import_completesentinel for skip-on-restart.
- Backups: All Pumperly data is covered by the existing Duplicacy appdata backup (daily at 1 AM to geiserback Garage, encrypted, deduplicated). No additional backup config needed.
- Caddy: Site block serves
pumperly.com, www.pumperly.com. Usesdynamic_dnsforpumperly.com. Alwayscaddy reload, never restart (Unraid FUSE stale file handle issue). - DB credentials: DB user/name all use
pumperly.
- Branch:
mainonly - Commits: Conventional commits (
feat:,fix:,chore:) - Identity:
GeiserX/[email protected]
- Portugal data is non-commercial — display with disclaimer
- Spain API blocks cloud IPs — scraper may need residential IP
- Italy coordinates are voluntary — geocode missing via Photon
- UK has 13 separate feeds — Shell excluded (returns HTML not JSON). Prices in GBP pence.
- Germany Tankerkoenig v4: 25km radius search limit, needs ~340 grid queries to cover country. HTTP 503 rate limiting.
- Valhalla multi-PBF bug: SIGABRT with
vector::_M_range_checkwhen building from multiple PBFs. Must merge withosmium mergefirst. - Valhalla tiles need monthly rebuilds from OSM data
- Valhalla warmup after restart: Cross-continent routes may fail for ~30-60s after container restart while tiles load into memory.
Before completing a task, verify:
- TypeScript strict mode
- No secrets committed
- Tests pass
- Linting passes (
npm run lint) - i18n: user-facing strings use
useI18n()/t(), never hardcoded - Spatial queries use PostGIS GiST indexes
- API responses include Zod validation
- Fuel types use EU harmonized codes
Generated by LynxPrompt
Last updated: April 2026