Skip to main content
Glama
cheaper-fuel
by jorgemgon
README.md
# cheaper-fuel

Fuel prices at filling stations in **mainland Portugal**, as a remote
[MCP](https://modelcontextprotocol.io) server **and** an installable Vue PWA dashboard, running on
Cloudflare Workers.

| | |
|---|---|
| **Dashboard** | <https://cheaper-fuel.jorgon.workers.dev/> |
| **MCP endpoint** | `https://cheaper-fuel.jorgon.workers.dev/mcp` |
| **REST API** | <https://cheaper-fuel.jorgon.workers.dev/api> |
| **Server docs** | <https://cheaper-fuel.jorgon.workers.dev/docs> |
| **Health** | <https://cheaper-fuel.jorgon.workers.dev/health> |

Data source: [DGEG — Direção-Geral de Energia e Geologia](https://precoscombustiveis.dgeg.gov.pt/).

## Quick start

```bash
# Use the dashboard in a browser, or install it (it is a PWA):
open https://cheaper-fuel.jorgon.workers.dev/

# Claude Code
claude mcp add --transport http cheaper-fuel https://cheaper-fuel.jorgon.workers.dev/mcp

# MCP Inspector
npx @modelcontextprotocol/inspector
# then connect to https://cheaper-fuel.jorgon.workers.dev/mcp
```

For Codex or any other client, add a remote MCP server pointing at the `/mcp` URL. The endpoint is
stateless Streamable HTTP and needs no authentication.

## Web dashboard

A Vue 3 PWA served from the same Worker (`/`), with a Favoritos home page and a separate Explore tab,
designed phone-first for use at the pump:

- **Favoritos** — the home page when this device has saved stations. Every Explore card carries a
  star; saved stations live in `localStorage` and open as a map plus one group per station showing
  **every fuel the station sells**, with prices and discounts refreshed from the station endpoint.
  With no favourites, `/` opens Explore and the Favoritos tab is hidden. Nothing is sent to a server,
  and the list is not filtered by the search controls; clearing site data clears it.
- **Explorar** — asks for your location (one tap) or takes a concelho/postcode, then shows the nearest
  stations on a **Leaflet map** with markers colour-coded by *effective price* after discounts,
  synchronised with the result list. It opens with the lowest price first and keeps that order when
  you use your location. The radius opens at 5 km and has a `Repor` action back to that default; the
  fuel filter opens with Gasóleo simples + Gasolina simples 95, the same pair the MCP defaults to.
  Every station inside the chosen radius is listed by default;
  `Resultados` can cap the list (10/20/50/100) for a slow connection, and the header says
  `20 de 84 postos` whenever a cap truncates what the radius actually holds. Every card/app and
  service starts selected, so the first search already reflects every discount the catalogue knows;
  each card leads with the best price after discounts and lists the remaining prices — the guaranteed
  slice, the base price and any other programme the caller qualifies for — so no price is hidden
  behind a mode switch. A **Litros / Euros** switch, a slider and an editable number field turn that
  into the spend and the saving for the fill you actually need
  (`50 L custam 91,00 € · poupas 15,00 €`), and every
  discounted card lists its programme's full level ladder — COMBINA's 1 / 2 / 3+ services, Repsol's
  base / electricity / gas, or a flat per-fill coupon — with each level's resulting price and the spend
  for the chosen quantity, and the active level highlighted. A programme only counts services from its
  own supplier/partners: COMBINA counts the four Galp/NOS services (electricity, gas, solar and the NOS
  package), while Repsol counts Repsol electricity/gas, so declaring an unrelated contract never
  unlocks a higher tier.
  Filters only restrict when they have a value: clearing the fuel chips requests every fuel, clearing
  cards/services goes back to list prices. Fuel, card and service groups carry `Tudo` (select every
  option) and `Limpar` actions, and each filter, the location and the quantity have a clear action.
  Selecting several fuels groups the cards by fuel — collapsible sections on desktop, a tabbed swipe
  carousel on phones — each with its own count and `20 de 84` when the results limit truncates it.
  Fuel filter chips and those section headers share an identity colour —
  diesel yellow, petrol lime green, GPL blue, specials in the same family — while the map keeps its
  cheap → dear price colours. When the selected fuels span different units (€/litro, €/kg, €/m³) the
  markers switch to each fuel's identity colour with a fuel legend, because those prices cannot share
  one ramp. The map carries a search pin: using your location drops it on your
  position, and clicking the map or dragging the pin re-searches from that point. The last search — a
  place name, the geolocation position or a picked point — is restored on reload, so a refresh keeps
  the same list on screen. The shell adapts to three sizes: wide desktops pin a filter rail and a tall
  map beside the results, mid-size desktops keep a collapsible filter bar over a map/results split,
  and phones use a `Filtros` drawer with the fuel carousel.
- **Mercado** — per-fuel min/average/median/max, spread, observed national discount and a comparison
  chart.
- **Histórico** — daily national averages as **Chart.js** line and bar charts (with and without discount).
- **Descontos** — the normalised catalogue: mechanics, caps, requirements, schedule, validity and sources.
- **Sobre** — coverage, freshness, attribution and the MCP connect command.

Installable (manifest + service worker) and usable offline: the last search and previously viewed map
tiles are cached. Location is opt-in, stored only on the device.

Map tiles come from a configurable provider (`VITE_TILE_URL` / `VITE_TILE_ATTRIBUTION`). The default is
**OpenStreetMap standard** tiles — no API key — inverted with CSS into a dark map. The earlier CARTO
default stopped working anonymously (their tiles now render "API KEY REQUIRED"), so a keyed provider such
as CARTO, Stadia or MapTiler can be supplied through those variables.

DGEG does not publish map tiles: its ArcGIS `PostosAbastecimento` service is query-only with no price
fields, so the map uses our own snapshot as the data source.

## REST API

The dashboard is backed by a read-only JSON API that calls exactly the same service functions as the
MCP tools.

| Endpoint | Returns |
|---|---|
| `GET /api/meta` | fuel catalogue, version, registry date, defaults |
| `GET /api/stations` | station search (`location`/`latitude`+`longitude`, `fuels`, `cards`, `services`, `sort`, …) |
| `GET /api/stations/:id` | full station detail with discounts |
| `GET /api/market` | per-fuel statistics (`fuels`, `district`, `municipality`) |
| `GET /api/history` | daily national averages (`fuels`, `days`/`from`/`to`) |
| `GET /api/discounts` | the standardized discount catalogue (`status`, `supplier`, `partner`, …) |
| `GET /api/fuels` | fuel types |
| `GET /api/locations` | concelho/locality/postcode lookup |

List parameters accept either comma-separated values (`?fuels=diesel,95`) or repeated keys. Endpoints
that return discount descriptions accept `lang=en` (default) or `lang=pt`; the dashboard asks for `pt`.
`/api/stations` also takes `sort` (`distance`, `price` or `effective_price`) and `sort_order`
(`asc` by default, `desc` for dearest first). `effective_price` ranks every station in the radius by
the lowest price after discounts, which is what the dashboard's single `Preço` order asks for; its
`limit` accepts a count (1–100 per fuel) or `all` for every station inside the radius, and it sends
`limit=all` unless `Resultados` narrows it.
Errors are `{ "error": { "code", "message", "hint?" } }` with 400 for validation, 404 for an unknown
station, 422 for coordinates outside mainland Portugal, 502 for upstream failures and 503 when the
discount registry is unavailable.


## Why not scrape the website?

The public page at `precoscombustiveis.dgeg.gov.pt` is a JavaScript shell. Its own scripts fetch JSON
from `https://precoscombustiveis.dgeg.gov.pt/api/PrecoComb/*`, so this server calls that same API
instead of parsing HTML:

- a stable schema instead of selectors that break on redeploy,
- `Access-Control-Allow-Origin: *` and `application/json`,
- it is the exact data source DGEG serves to its own site,
- `robots.txt` does not restrict `/api/`.

## Tools

Seven tools, deliberately few, so an agent does not wander between lookups.

| Tool | Use it for |
|---|---|
| `find_stations` | **The workhorse.** Cheapest stations near a place / postal code / coordinates, or within a concelho or district. Accepts friendly fuel and brand names, and reports the effective price after the loyalty cards the user declares. |
| `get_station_details` | One station: every fuel price, opening hours, services, payment methods, plus the discounts that apply to it. |
| `get_market_overview` | Per-fuel min / average / median / max, price spread, and the observed national discount. |
| `get_price_history` | Daily national average prices and the observed discount, with the change over the period. |
| `list_discount_programs` | The standardized discount catalogue from D1: supplier, retail partners, mechanics, caps, validity and provenance. |
| `list_fuel_types` | Fuel catalogue (id, name, unit, road-fuel flag). |
| `list_locations` | Resolve concelhos, localities and postal codes. |

### Designed so agents make one call, not five

- **No prerequisite lookups.** Every tool accepts human names — `"diesel"`, `"gasóleo"`, `"95"`,
  `"gpl"`, `"Leiria"`, `"2400-193"`, `"galp"` — and resolves them internally. Numeric ids are
  optional.
- **No coordinate requirement.** `location` takes a city, a locality, a postal code (`2400-193`,
  `2400193`, `2400`), or `"lat,lon"`. A full code DGEG never published keeps working: it degrades to
  its 4-digit prefix, so `2430-304` resolves to the 2430 area (Marinha Grande) instead of failing.
- **One call returns everything**: price, formatted display price, address, coordinates, distance,
  map link and freshness.
- **Self-widening radius.** If a small concelho has too few stations, `find_stations` grows the radius
  up to `max_radius_km` and reports `effective_radius_km`. With `limit: "all"` the chosen radius is the
  boundary instead, and every station inside it comes back.
- **Nothing hidden.** `limit: "all"` returns every station in the radius with no per-fuel cap; the
  default (5 per fuel) and any numeric `limit` truncation stays visible in the response through
  `stations_found`.
- **Server `instructions`** in the MCP handshake give the model a routing table, so it picks the right
  tool first time.
- **Errors terminate loops.** Failures return `{ error: { code, message, hint, candidates? } }` with a
  concrete next step instead of an empty result.

### Examples

```jsonc
// What is diesel around Leiria right now?
{ "location": "Leiria", "fuels": ["diesel"], "limit": 3 }

// I only know my postcode.
{ "location": "2400-193" }

// Cheapest petrol 95 in Porto district, supermarkets only.
{ "district": "Porto", "fuels": ["gasolina 95"], "station_type": "supermercado", "limit": 5 }

// Prices published in the last 24 hours only.
{ "location": "Coimbra", "fuels": ["diesel"], "updated_within_hours": 24 }

// How has diesel moved this quarter?
{ "fuels": ["diesel"], "days": 90 }          // get_price_history
```

A proximity call returns the neighbouring concelhos it covered:

```jsonc
"municipalities_covered": [
  { "municipality": "Leiria",         "station_count": 14, "min_distance_km": 0.42 },
  { "municipality": "Marinha Grande", "station_count": 6,  "min_distance_km": 8.11 },
  { "municipality": "Batalha",        "station_count": 4,  "min_distance_km": 11.7 }
]
```

## Discounts

Discounts come from **two independent sources**, and they are deliberately never added together.

### 1. Observed discounts (automatic, DGEG)

DGEG's daily series publishes both a discounted and an undiscounted national average. The gap is a real,
observable discount measure:

```
observed_discount_eur = avg_without_discount − avg
```

`get_price_history` reports it per day plus a period summary; `get_market_overview` reports the latest
value per fuel. It is a **national weighted average** across all stations and discount types (fleet cards
and similar), so it is not attributable to a supplier or station.

### 2. Programme catalogue (curated, stored in D1)

Supplier and retail-chain deals — Galp × Continente/NOS, BP × Pingo Doce, Repsol — are marketing
promotions with no machine-readable source. They live as **standardized objects in Cloudflare D1**
(`discounts` table), not in the bundle.

One row = one atomic benefit. A composite programme such as COMBINA (an app discount *plus* a supermarket
coupon, tiered by how many services you hold) is several rows sharing `programId` + `tierId`.

```jsonc
{
  "id": "galp-combina-1-coupon",
  "programId": "galp-combina",
  "programTitle": "COMBINA",
  "description": "12 c/L coupon credited to the Cartão Continente after a grocery shop of at least €30 …",
  "tierId": "combina-1",
  "kind": "loyalty_tier",
  "supplier": "GALP",
  "partners": ["CONTINENTE", "NOS"],
  "stationMatch": { "brands": ["GALP"], "namePrefixes": ["Galp", "PA GALP", "PB GALP"] },
  "requires": ["continente_card", "spend:continente_30"],
  "minServices": 1,
  "channel": "coupon",
  "valueType": "cents_per_litre",
  "value": 12,
  "combine": "sum",
  "caps": { "litresPerFill": 60, "litresPerYear": null },
  "schedule": { "kind": "always", "weekdays": [], "daysOfMonth": [], "nth": null, "weekday": null },
  "validFrom": null,
  "validUntil": null,
  "confidence": "high",
  "source": { "url": "…", "retrievedAt": "2026-10-23", "contentHash": null, "fetchMode": "auto", "notes": "…" },
  "updatedAt": "2026-10-23"
}
```

**There is no `status` column.** Activeness is always derived from `validFrom`/`validUntil` against today
in Europe/Lisbon — `scheduled`, `active` or `expired`. Editing one date is enough; nothing can drift.

Every discount also carries a required **`description`** in **both English and Portuguese**
(`description: { en, pt }`). The API and the MCP tools resolve it to a single string using `lang`
(`en` by default, `pt` for the dashboard), so a client never has to reconstruct the mechanic from
numbers and note fields.

Promotions are always listed **active first**, then `scheduled`, then `expired`; supplier and title only
break ties within the same status.

### Day-restricted offers

Some deals only run on particular days. `schedule` expresses that:

| `kind` | Meaning |
|---|---|
| `always` | every day (default) |
| `weekdays` | only on `weekdays`, ISO numbers 1 = Monday … 7 = Sunday |
| `day_of_month` | only on the listed `daysOfMonth` |
| `nth_weekday_of_month` | only on the Nth `weekday`, e.g. the first Wednesday |

```jsonc
"schedule": { "kind": "nth_weekday_of_month", "weekdays": [], "daysOfMonth": [], "nth": 1, "weekday": 3 }
```

An offer that is not valid today is **never applied** to the effective price. It is returned under
`scheduled_discounts` with its structured `schedule`, `schedule_label` and `next_date`, so an agent can answer "that one runs
on the first Wednesday — next on 2026-11-04" instead of silently showing a price that isn't available.

**What could actually be verified:**

- **Intermarché** — €10 in Cartão Poupança on fills of €50 or more, on the **first Wednesday of each
  month**, at participating stations; the credit is then spent in Intermarché stores from the following
  Thursday to Sunday. Seeded at `medium` confidence (the recurrence comes from a third-party guide,
  while Intermarché promo blogs report one-off Wednesday dates), so it is reported but never applied.
- **BP** — a third-party guide reports 6 c/L on weekdays, 10 c/L at weekends and 15 c/L on the 15th for
  Ultimate. No official page confirms it, so it is **not seeded**.
- **Pingo Doce × BP** — the "20 € fuel" Friday/Saturday/Sunday voucher found online is from **2021**,
  not a current programme, so it is not seeded.

`schedule` is deliberately separate from `validFrom`/`validUntil`: a date range says when the programme
exists, a schedule says which days inside it pay out.

Requirements prefixed `spend:` are conditions the user can trigger by shopping. Everything else is
something they must already hold, and is gated on the `cards` they declare.

### Effective price

```jsonc
{ "location": "Leiria", "fuels": ["diesel"], "brand": "galp",
  "cards": ["continente_card", "mundo_galp_app"], "services": ["galp_energy"] }
```

adds a `discount` block to each station:

```jsonc
"discount": {
  "program_id": "galp-combina", "tier": "combina-1", "label": "1 serviço",
  "guaranteed_cents": 8, "max_cents": 20, "applied_cents": 20,
  "effective_price_eur": 2.099, "effective_price_display": "2,099 €/litro",
  "saves_eur_per_fill": 10,
  "confidence": "high", "stale": false,
  "levels": [
    { "tier": "combina-1", "label": "1 serviço", "min_services": 1,
      "value_type": "cents_per_litre", "value": 20, "cents_per_litre": 20,
      "effective_price_eur": 2.099, "effective_price_display": "2,099 €/litro" },
    { "tier": "combina-2", "label": "2 serviços", "min_services": 2,
      "value_type": "cents_per_litre", "value": 25, "cents_per_litre": 25,
      "effective_price_eur": 2.049, "effective_price_display": "2,049 €/litro" },
    { "tier": "combina-3", "label": "3+ serviços", "min_services": 3,
      "value_type": "cents_per_litre", "value": 30, "cents_per_litre": 30,
      "effective_price_eur": 1.999, "effective_price_display": "1,999 €/litro" }
  ]
}
```

- `discount_mode: "guaranteed"` counts only permanent app/instant benefits; the default `best_case` also
  counts spend-triggered coupons.
- `fill_litres` (default 50) makes per-fill caps realistic.
- `levels` lists every tier of the programme with the price each tier would give (best case, without
  applying weekday schedules), so a smaller discount can be compared with the applied one.
- COMBINA's service keys are `galp_energy` (electricity), `galp_gas`, `galp_solar` and `nos_package`;
  any three unlock the 3+ tier.
- **Only `confidence: "high"` programmes affect `effective_price`.** Medium/low entries appear as
  `possible_discounts` with `verify: true`, so an unverified mechanic can never produce a wrong ranking.
- Programmes whose mechanic is published without a number are stored **unquantified** (`value: null`)
  and listed as `catalogued_discounts` rather than guessed.
- If D1 is unavailable the price tools keep working and set `discounts_unavailable`.

### Verified partner programmes

| Programme | Supplier × partner | Value stored | Source |
|---|---|---|---|
| COMBINA (3 tiers) | Galp × Continente, NOS | 8–10 c/L app + 12–20 c/L coupon | Galp |
| Plano Motor (3 tiers) | Repsol | 8 / 13 / 18 c/L by energy contracts held | Repsol |
| Lidl Plus × Repsol | Repsol × Lidl | 6 c/L with the app; **10 c/L** when linked to My Repsol (6 immediate + 4 balance) | campaign regulation |
| Poupa Mais | BP × Pingo Doce | €2 per €40 of groceries (≈4 c/L on a 50 L fill) | Pingo Doce |
| Primeira quarta-feira | Intermarché | €10 Cartão Poupança on €50+ fuel, first Wednesday (`medium`) | third-party guide |

The Lidl Plus × Repsol campaign runs **1 Jan – 31 Dec 2026** in mainland Portugal and Madeira, and also
grants a €3 Lidl coupon for every €30 of fuel (max 3 per day, 15-day validity). That coupon is a Lidl
store coupon, so it is described in the discount notes but deliberately **not** priced into the fuel
effective price.

### Keeping it honest

- `npm run discounts:check` — offline gate in CI: schema validation, unique ids, ordered validity
  windows, and no still-applicable programme whose source is more than 90 days old.
- `npm run discounts:verify` — weekly, network: hashes each official source and reports
  `UNCHANGED / CHANGED / BLOCKED / ERROR`. **Change detection only — it never rewrites a promotion.**
- `.github/workflows/discounts-watch.yml` opens or updates an issue when a source moves.

To change a discount: edit `data/discounts.seed.json`, then

```bash
npm run discounts:migration -- --name refresh-discounts   # generate the next migration
npm run db:migrate:remote                                  # apply it to D1
```

> Discounts are **advisory**. Terms belong to the operators, campaigns end, caps apply, and DGEG's list
> price may already include some fleet-card discount. Always confirm at the pump.

## How it works

```
DGEG JSON API
   └─ daily national snapshot (~14,300 station×fuel rows, paged, compacted) … cached 24 h
        ├─ place index   → concelhos, localities, postal codes mapped to median coordinates
        ├─ geo           → haversine, radius widening, concelho grouping
        └─ MCP tools     → find_stations · get_market_overview · get_station_details
   PMD (daily averages) → get_price_history
   GetDadosPosto        → get_station_details (by id)
   D1 `discounts` table → effective prices, list_discount_programs
```

- **One upstream snapshot per day** powers proximity, regional and market queries. It is cached in the
  Cloudflare Cache API for `CACHE_TTL_SECONDS` (default 86400) and memoised per isolate, so warm
  requests skip the ~1.8 MB JSON parse.
- **Place resolution needs no external geocoder.** The snapshot yields 278 concelhos, ~1,770
  localities and ~2,680 postal codes; each gets a median coordinate.
- **"Neighbouring concelhos" come from geometry**, not a hand-maintained adjacency table: the search
  radius widens until every requested fuel has enough results, and `municipalities_covered` lists
  exactly which concelhos were included.

## Data freshness and coverage

Prices are **cached for up to 24 hours**. Every response carries:

- `data_fetched_at` — when this server last read DGEG (the true cache age),
- `last_updated` (per station) or `date` (per history point) — DGEG's own publication timestamp.

Treat results as *as published by DGEG*, never as real-time quotes.

**Coverage is mainland Portugal only** — 18 districts, 278 concelhos. DGEG publishes no Azores or
Madeira stations, so island coordinates return an explicit `unsupported_region` error rather than an
empty list.

`get_price_history` is **national** because that is the only granularity DGEG publishes; there is no
per-concelho or per-station history endpoint.

## Local development

```bash
npm install
npm run db:migrate:local   # create + seed the local D1 discounts table
npm run web:build          # build the dashboard into web/dist (needed by wrangler dev)
npm run dev                # wrangler dev on http://127.0.0.1:8787

node test/smoke.mjs        # end-to-end checks (MCP + REST + PWA shell)
npm run typecheck          # worker + Vue app
npm test
npm run discounts:check
```

For fast UI iteration run the Vite dev server instead, which proxies the API to the worker:

```bash
# terminal 1
npx wrangler dev --port 8787
# terminal 2
npm run dev:web            # http://127.0.0.1:5173
```

`node test/smoke.mjs https://cheaper-fuel.jorgon.workers.dev/mcp` runs the same checks against a
deployment.

## Deploy

```bash
npm run deploy   # builds the dashboard, then wrangler deploy
```

The Worker serves the SPA from `web/dist` via Cloudflare static assets; `/api/*`, `/mcp`, `/health`
and `/docs` are routed to the Worker (`run_worker_first`), everything else falls back to the SPA shell.

Configuration lives in [`wrangler.jsonc`](wrangler.jsonc):

| Variable | Default | Purpose |
|---|---|---|
| `DGEG_BASE_URL` | `https://precoscombustiveis.dgeg.gov.pt/api/PrecoComb` | Upstream API |
| `CACHE_TTL_SECONDS` | `86400` | Cache lifetime for every upstream call |
| `USER_AGENT` | `cheaper-fuel-mcp/<version> (+repo)` | Identifying agent sent to DGEG |
| `DB` (binding) | `cheaper-fuel-discounts` | D1 database holding the standardized discount objects |

Dashboard-only build-time variables: `VITE_TILE_URL` and `VITE_TILE_ATTRIBUTION` override the basemap.

To auto-deploy on push, connect the repository in the Cloudflare dashboard (Workers → Git
integration) or add `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` repository secrets.

## Data source, attribution and disclaimer

DGEG publishes this information **free of charge for reuse**, provided that:

1. DGEG is clearly identified as the source, and
2. the date and time of the last price update are shown.

Both requirements are met in every tool response (`source`, `last_updated`) and on the landing page.

Prices are **reported by station operators**, not measured by DGEG. This project re-publishes them
as-is and guarantees nothing about their accuracy. Not affiliated with or endorsed by DGEG.

Discount programmes are compiled from the operators' own public pages. They are third-party terms that
change without notice, and the catalogue records the source URL and retrieval date for each one. It is
**not** an official DGEG dataset and carries no endorsement from any supplier or retailer.

## Limitations and roadmap

- **Discounts are curated, not scraped.** Promotions have no machine-readable source, the mechanics are
  tiered and capped, and third-party summaries are demonstrably wrong; the registry trades freshness
  automation for correctness. Automation covers validation and change detection only.
- **Guaranteed figures, not headlines.** Repsol advertises "up to 10/15/20 c/L" but its benefits table
  states **8 / 13 / 18 c/L**, so the table values are stored and the headline is only echoed in the
  notes. Time-boxed, fuel-grade-specific campaigns (e.g. bp Ultimate vouchers) are catalogued but not
  applied, because DGEG does not distinguish those grades.
- **No per-station price history.** DGEG publishes daily averages nationally only; per-station
  tracking would need our own D1 + cron snapshots.
- **No route/corridor search** ("cheapest on the way to Porto").
- **No island coverage** — upstream limitation.
- **All fuels is a big response**: selecting every fuel at once can return a few hundred KB and
  hundreds of cards; `Resultados` caps it, and offscreen fuel sections use `content-visibility` so the
  page stays responsive.
- **Free-plan CPU**: the cold parse of the daily snapshot is the heaviest request; a Workers Paid plan
  gives comfortable headroom.

## Project layout

```
shared/
  contracts.ts       response interfaces shared by the worker and the dashboard
src/
  index.ts           HTTP surface: /mcp, /api/*, /health, /docs (SPA served from assets)
  api.ts             read-only JSON API used by the dashboard
  service.ts         the single implementation both surfaces call
  tool-inputs.ts     zod schemas shared by MCP and REST validation
  mcp.ts             MCP server: instructions + the seven tools (thin wrappers)
  discounts.ts       D1 repository, profile resolution and effective pricing
  discount-schema.ts the standardized Discount object, zod schema, derived status and schedules
  snapshot.ts        paged national snapshot, cached and memoised
  places.ts          place index and name/postal-code/coordinate resolution
  geo.ts             haversine, radius widening, concelho grouping
  dgeg.ts            cache helpers, upstream client, typed errors
  domain.ts          pure parsing, matching and statistics
  landing.ts         HTML documentation page served at /docs
web/
  index.html         SPA entry
  vite.config.ts     Vite + Vue + PWA (Workbox) + dev proxy
  public/            generated PWA icons
  src/
    api.ts           typed client for /api
    views/           Favorites (home), Explore (map + list), Overview, History, Discounts, About
    components/      MapPanel (Leaflet), charts (Chart.js), StationCard, ChipGroup, StatCard
    composables/     geolocation, profile persistence, Leaflet lifecycle
    lib/             formatting, price colour ramp, geo maths, storage
data/
  discounts.seed.json  authoring source for the discount registry (not bundled)
migrations/
  0001_discounts.sql   D1 schema (no status column)
  0002..0006_*.sql     generated seed and follow-up migrations
scripts/
  build-seed-migration.mjs     seed -> migration
  verify-discount-sources.mjs  source change detection
  generate-icons.py            PWA icons
test/
  *.test.ts          unit tests (vitest): service, api, discounts, domain
  smoke.mjs          end-to-end checks: MCP client, REST API and PWA shell
```

## License

MIT — see [LICENSE](LICENSE).