smol change
This commit is contained in:
parent
cdec0834e9
commit
6922e9d63b
3 changed files with 668 additions and 11 deletions
197
AGENTS.md
Normal file
197
AGENTS.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Guidance for agentic coding agents working in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Essence** is a Quebec gas price heatmap — a minimal, self-contained web application with two source files:
|
||||||
|
|
||||||
|
- `main.go` — the entire Go backend (HTTP server, SQLite persistence, upstream polling)
|
||||||
|
- `static/index.html` — the entire frontend (Leaflet map, Chart.js charts, vanilla JS)
|
||||||
|
|
||||||
|
Keep this architecture. Do not split `main.go` into multiple files or introduce a frontend build step unless explicitly asked.
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
The dev environment is managed with Nix flakes. Enter it with either:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop # explicit
|
||||||
|
direnv allow # automatic via .envrc (uses `use flake`)
|
||||||
|
```
|
||||||
|
|
||||||
|
The shell provides: `go`, `gopls`, `gotools` (includes `gofmt`, `goimports`).
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Build binary
|
||||||
|
go build -o essence .
|
||||||
|
|
||||||
|
# Run directly (no build step needed for development)
|
||||||
|
go run .
|
||||||
|
|
||||||
|
# With explicit env vars (both have defaults)
|
||||||
|
PORT=8080 ESSENCE_DB=./essence.db go run .
|
||||||
|
|
||||||
|
# Build via Nix
|
||||||
|
nix build
|
||||||
|
./result/bin/essence
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- `PORT` — HTTP listen port (default: `8080`)
|
||||||
|
- `ESSENCE_DB` — SQLite database file path (default: `./essence.db`)
|
||||||
|
|
||||||
|
## Lint & Format
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gofmt -w . # format all Go files (authoritative formatter — no config)
|
||||||
|
goimports -w . # format + organize imports (superset of gofmt)
|
||||||
|
go vet ./... # static analysis
|
||||||
|
```
|
||||||
|
|
||||||
|
There are no linter config files. Follow standard Go formatting conventions enforced by `gofmt`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run a single test by name
|
||||||
|
go test -run TestFunctionName .
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
go test -v -run TestFunctionName .
|
||||||
|
```
|
||||||
|
|
||||||
|
No tests exist yet. When adding tests, use Go's built-in `testing` package — no third-party test libraries. Place test files alongside the code they test (e.g., `main_test.go`).
|
||||||
|
|
||||||
|
## Go Code Style
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
Group imports into two blocks separated by a blank line: stdlib first, then third-party. This is enforced by `goimports`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- Exported types, functions, fields: `PascalCase`
|
||||||
|
- Unexported variables, functions: `camelCase`
|
||||||
|
- Acronyms follow Go convention: `geojsonURL`, `initDB`, `handleStations`
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Always check errors immediately. Wrap errors with context using `%w`. Use `log.Fatalf` for unrecoverable startup errors; `log.Printf` + early return for runtime errors.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Startup — fatal is appropriate
|
||||||
|
db, err = initDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapping with context
|
||||||
|
return nil, fmt.Errorf("create table: %w", err)
|
||||||
|
return nil, fmt.Errorf("fetching data: %w", err)
|
||||||
|
|
||||||
|
// Runtime — log and return, do not crash
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("fetch error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Never silently discard errors.
|
||||||
|
|
||||||
|
### Types & Structs
|
||||||
|
|
||||||
|
Use struct-based JSON serialization with `json` tags. Use anonymous inline structs for one-off local parsing; define named types for anything reused or returned from functions.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Station struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Regular float64 `json:"regular"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline anonymous struct for local, single-use parsing
|
||||||
|
var features []struct {
|
||||||
|
Geometry struct {
|
||||||
|
Coordinates [2]float64 `json:"coordinates"`
|
||||||
|
} `json:"geometry"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
Group related constants in a single `const` block.
|
||||||
|
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
geojsonURL = "https://example.com/stations.geojson.gz"
|
||||||
|
defaultPort = "8080"
|
||||||
|
pollInterval = 5 * time.Minute
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concurrency
|
||||||
|
|
||||||
|
Use `sync.RWMutex` to protect shared state. Acquire the narrowest lock needed.
|
||||||
|
|
||||||
|
```go
|
||||||
|
var (
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
cachedResp *StationsResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
cacheMu.Lock()
|
||||||
|
cachedResp = resp
|
||||||
|
cacheMu.Unlock()
|
||||||
|
|
||||||
|
cacheMu.RLock()
|
||||||
|
resp := cachedResp
|
||||||
|
cacheMu.RUnlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
Write doc-style comments on all exported types and non-trivial unexported functions. Keep comments concise and sentence-cased.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Station holds the parsed data for a single fuel station.
|
||||||
|
type Station struct { ... }
|
||||||
|
|
||||||
|
// poller fetches upstream data immediately, then repeats every pollInterval.
|
||||||
|
func poller() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Code Style (`static/index.html`)
|
||||||
|
|
||||||
|
- **No framework, no build step.** Plain HTML, CSS, and JavaScript in a single file.
|
||||||
|
- **Inline** `<style>` and `<script>` blocks — no external local JS/CSS files.
|
||||||
|
- **CDN** dependencies only: Leaflet, leaflet.markercluster, Chart.js, `@knadh/oat` CSS.
|
||||||
|
- **`camelCase`** for all JS variables and function names.
|
||||||
|
- **`fetch()` with `.then()` promise chains** — not `async/await`.
|
||||||
|
- **CSS custom properties** (`--primary`, `--border`, etc.) from the `oat` design token system.
|
||||||
|
- **French language** — all user-facing text must be in French (`lang="fr"` on `<html>`).
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- Static assets are embedded into the binary at compile time via `//go:embed static/*`. No file serving from disk at runtime.
|
||||||
|
- The SQLite driver is `modernc.org/sqlite` — a pure-Go, CGo-free implementation. Do not introduce CGo or replace this driver.
|
||||||
|
- Deployment target is a NixOS systemd service defined in `nixos-module.nix`.
|
||||||
|
- The module `github.com/polen/essence` targets Go 1.25+.
|
||||||
340
main.go
340
main.go
|
|
@ -113,6 +113,9 @@ func main() {
|
||||||
|
|
||||||
http.HandleFunc("/api/stations", handleStations)
|
http.HandleFunc("/api/stations", handleStations)
|
||||||
http.HandleFunc("/api/stats", handleStats)
|
http.HandleFunc("/api/stats", handleStats)
|
||||||
|
http.HandleFunc("/api/regions", handleRegions)
|
||||||
|
http.HandleFunc("/api/stats/region", handleRegionStats)
|
||||||
|
http.HandleFunc("/api/station-deltas", handleStationDeltas)
|
||||||
http.Handle("/", http.FileServer(http.FS(staticSub)))
|
http.Handle("/", http.FileServer(http.FS(staticSub)))
|
||||||
|
|
||||||
log.Printf("Listening on http://localhost:%s", port)
|
log.Printf("Listening on http://localhost:%s", port)
|
||||||
|
|
@ -141,6 +144,47 @@ func initDB(path string) (*sql.DB, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create table: %w", err)
|
return nil, fmt.Errorf("create table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS region_snapshots (
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
region TEXT NOT NULL,
|
||||||
|
regular_avg REAL,
|
||||||
|
regular_min REAL,
|
||||||
|
regular_max REAL,
|
||||||
|
super_avg REAL,
|
||||||
|
diesel_avg REAL,
|
||||||
|
station_count INTEGER,
|
||||||
|
PRIMARY KEY (generated_at, region)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create region_snapshots table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS station_prices (
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
regular REAL,
|
||||||
|
super REAL,
|
||||||
|
diesel REAL,
|
||||||
|
PRIMARY KEY (address, generated_at)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create station_prices table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the last 48 hours of per-station price history to bound table size.
|
||||||
|
_, err = d.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_station_prices_generated_at
|
||||||
|
ON station_prices (generated_at)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create station_prices index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,10 +215,9 @@ func fetchAndStore() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute aggregates.
|
// Compute and persist global aggregate.
|
||||||
snap := computeSnapshot(resp)
|
snap := computeSnapshot(resp)
|
||||||
|
|
||||||
// Only insert if this generated_at is new.
|
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
INSERT OR IGNORE INTO snapshots
|
INSERT OR IGNORE INTO snapshots
|
||||||
(generated_at, fetched_at, regular_avg, regular_min, regular_max, super_avg, diesel_avg, station_count)
|
(generated_at, fetched_at, regular_avg, regular_min, regular_max, super_avg, diesel_avg, station_count)
|
||||||
|
|
@ -191,6 +234,49 @@ func fetchAndStore() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("db insert error: %v", err)
|
log.Printf("db insert error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute and persist per-region aggregates.
|
||||||
|
regionSnaps := computeRegionSnapshots(resp)
|
||||||
|
for _, rs := range regionSnaps {
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO region_snapshots
|
||||||
|
(generated_at, region, regular_avg, regular_min, regular_max, super_avg, diesel_avg, station_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
rs.GeneratedAt,
|
||||||
|
rs.Region,
|
||||||
|
rs.RegularAvg,
|
||||||
|
rs.RegularMin,
|
||||||
|
rs.RegularMax,
|
||||||
|
rs.SuperAvg,
|
||||||
|
rs.DieselAvg,
|
||||||
|
rs.StationCount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("db region insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist per-station prices for 24h delta calculations.
|
||||||
|
for _, s := range resp.Stations {
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO station_prices (address, generated_at, regular, super, diesel)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
s.Address,
|
||||||
|
resp.LastUpdated,
|
||||||
|
s.Regular,
|
||||||
|
s.Super,
|
||||||
|
s.Diesel,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("db station_prices insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune station_prices rows older than 48 hours.
|
||||||
|
cutoff := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339)
|
||||||
|
if _, err = db.Exec(`DELETE FROM station_prices WHERE generated_at < ?`, cutoff); err != nil {
|
||||||
|
log.Printf("db station_prices prune error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeSnapshot derives aggregate statistics from a StationsResponse.
|
// computeSnapshot derives aggregate statistics from a StationsResponse.
|
||||||
|
|
@ -243,6 +329,81 @@ func computeSnapshot(resp *StationsResponse) Snapshot {
|
||||||
return snap
|
return snap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegionSnapshot holds aggregated statistics for one fetch scoped to a region.
|
||||||
|
type RegionSnapshot struct {
|
||||||
|
GeneratedAt string `json:"generatedAt"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
RegularAvg float64 `json:"regularAvg"`
|
||||||
|
RegularMin float64 `json:"regularMin"`
|
||||||
|
RegularMax float64 `json:"regularMax"`
|
||||||
|
SuperAvg float64 `json:"superAvg"`
|
||||||
|
DieselAvg float64 `json:"dieselAvg"`
|
||||||
|
StationCount int `json:"stationCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeRegionSnapshots derives per-region aggregate statistics from a StationsResponse.
|
||||||
|
func computeRegionSnapshots(resp *StationsResponse) []RegionSnapshot {
|
||||||
|
type acc struct {
|
||||||
|
regularSum, superSum, dieselSum float64
|
||||||
|
regularCount, superCount, dieselCount int
|
||||||
|
regularMin, regularMax float64
|
||||||
|
stationCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
byRegion := map[string]*acc{}
|
||||||
|
for _, s := range resp.Stations {
|
||||||
|
if s.Region == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, ok := byRegion[s.Region]
|
||||||
|
if !ok {
|
||||||
|
a = &acc{regularMin: 1<<53 - 1}
|
||||||
|
byRegion[s.Region] = a
|
||||||
|
}
|
||||||
|
a.stationCount++
|
||||||
|
if s.Regular > 0 {
|
||||||
|
a.regularSum += s.Regular
|
||||||
|
a.regularCount++
|
||||||
|
if s.Regular < a.regularMin {
|
||||||
|
a.regularMin = s.Regular
|
||||||
|
}
|
||||||
|
if s.Regular > a.regularMax {
|
||||||
|
a.regularMax = s.Regular
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Super > 0 {
|
||||||
|
a.superSum += s.Super
|
||||||
|
a.superCount++
|
||||||
|
}
|
||||||
|
if s.Diesel > 0 {
|
||||||
|
a.dieselSum += s.Diesel
|
||||||
|
a.dieselCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snaps := make([]RegionSnapshot, 0, len(byRegion))
|
||||||
|
for region, a := range byRegion {
|
||||||
|
rs := RegionSnapshot{
|
||||||
|
GeneratedAt: resp.LastUpdated,
|
||||||
|
Region: region,
|
||||||
|
StationCount: a.stationCount,
|
||||||
|
}
|
||||||
|
if a.regularCount > 0 {
|
||||||
|
rs.RegularAvg = a.regularSum / float64(a.regularCount)
|
||||||
|
rs.RegularMin = a.regularMin
|
||||||
|
rs.RegularMax = a.regularMax
|
||||||
|
}
|
||||||
|
if a.superCount > 0 {
|
||||||
|
rs.SuperAvg = a.superSum / float64(a.superCount)
|
||||||
|
}
|
||||||
|
if a.dieselCount > 0 {
|
||||||
|
rs.DieselAvg = a.dieselSum / float64(a.dieselCount)
|
||||||
|
}
|
||||||
|
snaps = append(snaps, rs)
|
||||||
|
}
|
||||||
|
return snaps
|
||||||
|
}
|
||||||
|
|
||||||
func handleStations(w http.ResponseWriter, r *http.Request) {
|
func handleStations(w http.ResponseWriter, r *http.Request) {
|
||||||
cacheMu.RLock()
|
cacheMu.RLock()
|
||||||
resp := cachedResp
|
resp := cachedResp
|
||||||
|
|
@ -308,6 +469,181 @@ func handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(StatsResponse{Snapshots: snapshots})
|
json.NewEncoder(w).Encode(StatsResponse{Snapshots: snapshots})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRegions returns a sorted list of distinct region names from region_snapshots.
|
||||||
|
func handleRegions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := db.Query(`SELECT DISTINCT region FROM region_snapshots ORDER BY region ASC`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
regions := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var region string
|
||||||
|
if err := rows.Scan(®ion); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db scan: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
regions = append(regions, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(regions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegionStats returns time-series snapshots for a specific region.
|
||||||
|
func handleRegionStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
region := r.URL.Query().Get("region")
|
||||||
|
if region == "" {
|
||||||
|
http.Error(w, "missing region parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
daysStr := r.URL.Query().Get("days")
|
||||||
|
days := 7
|
||||||
|
if daysStr != "" {
|
||||||
|
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if daysStr == "0" {
|
||||||
|
rows, err = db.Query(`
|
||||||
|
SELECT generated_at, regular_avg, regular_min, regular_max,
|
||||||
|
super_avg, diesel_avg, station_count
|
||||||
|
FROM region_snapshots
|
||||||
|
WHERE region = ?
|
||||||
|
ORDER BY generated_at ASC
|
||||||
|
`, region)
|
||||||
|
} else {
|
||||||
|
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
rows, err = db.Query(`
|
||||||
|
SELECT generated_at, regular_avg, regular_min, regular_max,
|
||||||
|
super_avg, diesel_avg, station_count
|
||||||
|
FROM region_snapshots
|
||||||
|
WHERE region = ? AND generated_at >= ?
|
||||||
|
ORDER BY generated_at ASC
|
||||||
|
`, region, since)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
snapshots := []Snapshot{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s Snapshot
|
||||||
|
if err := rows.Scan(&s.GeneratedAt, &s.RegularAvg, &s.RegularMin, &s.RegularMax,
|
||||||
|
&s.SuperAvg, &s.DieselAvg, &s.StationCount); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db scan: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshots = append(snapshots, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(StatsResponse{Snapshots: snapshots})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StationDelta holds the price change percentage for a station over the available
|
||||||
|
// history window. Fields are pointers so that missing data serialises as JSON null.
|
||||||
|
// ElapsedHours is the actual time span between the oldest and newest snapshot used.
|
||||||
|
type StationDelta struct {
|
||||||
|
Regular *float64 `json:"regular"`
|
||||||
|
Super *float64 `json:"super"`
|
||||||
|
Diesel *float64 `json:"diesel"`
|
||||||
|
ElapsedHours float64 `json:"elapsedHours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStationDeltas returns a map of address → StationDelta for all stations
|
||||||
|
// that have at least two distinct price records within the 48h retention window.
|
||||||
|
// The delta is computed between the oldest and newest available snapshot, and
|
||||||
|
// ElapsedHours carries the actual time span so the frontend can label it honestly.
|
||||||
|
func handleStationDeltas(w http.ResponseWriter, r *http.Request) {
|
||||||
|
since := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
|
// Fetch all rows in the retention window, oldest first.
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT address, generated_at, regular, super, diesel
|
||||||
|
FROM station_prices
|
||||||
|
WHERE generated_at >= ?
|
||||||
|
ORDER BY address ASC, generated_at ASC
|
||||||
|
`, since)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type priceRow struct {
|
||||||
|
generatedAt string
|
||||||
|
regular float64
|
||||||
|
super float64
|
||||||
|
diesel float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type addrRows struct {
|
||||||
|
old *priceRow // first (oldest) row seen for this address
|
||||||
|
cur *priceRow // last (newest) row seen for this address
|
||||||
|
}
|
||||||
|
byAddr := map[string]*addrRows{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var addr, genAt string
|
||||||
|
var reg, sup, die float64
|
||||||
|
if err := rows.Scan(&addr, &genAt, ®, &sup, &die); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db scan: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ar, ok := byAddr[addr]
|
||||||
|
if !ok {
|
||||||
|
ar = &addrRows{}
|
||||||
|
byAddr[addr] = ar
|
||||||
|
}
|
||||||
|
row := &priceRow{generatedAt: genAt, regular: reg, super: sup, diesel: die}
|
||||||
|
// First row seen becomes the baseline; every row updates current.
|
||||||
|
if ar.old == nil {
|
||||||
|
ar.old = row
|
||||||
|
}
|
||||||
|
ar.cur = row
|
||||||
|
}
|
||||||
|
|
||||||
|
pctChange := func(cur, old float64) *float64 {
|
||||||
|
if old <= 0 || cur <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := (cur - old) / old * 100
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]StationDelta, len(byAddr))
|
||||||
|
for addr, ar := range byAddr {
|
||||||
|
// Need at least two distinct snapshots to compute a meaningful delta.
|
||||||
|
if ar.old == nil || ar.cur == nil || ar.old.generatedAt == ar.cur.generatedAt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldT, err1 := time.Parse(time.RFC3339, ar.old.generatedAt)
|
||||||
|
curT, err2 := time.Parse(time.RFC3339, ar.cur.generatedAt)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[addr] = StationDelta{
|
||||||
|
Regular: pctChange(ar.cur.regular, ar.old.regular),
|
||||||
|
Super: pctChange(ar.cur.super, ar.old.super),
|
||||||
|
Diesel: pctChange(ar.cur.diesel, ar.old.diesel),
|
||||||
|
ElapsedHours: curT.Sub(oldT).Hours(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
func fetchAndParse() (*StationsResponse, error) {
|
func fetchAndParse() (*StationsResponse, error) {
|
||||||
log.Println("Fetching GeoJSON data from upstream...")
|
log.Println("Fetching GeoJSON data from upstream...")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,12 @@
|
||||||
<div id="page-stats">
|
<div id="page-stats">
|
||||||
<h2>Statistiques des prix</h2>
|
<h2>Statistiques des prix</h2>
|
||||||
|
|
||||||
<!-- Summary cards -->
|
<!-- Region filter -->
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<select id="stats-region-select" style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); color: var(--secondary-foreground); cursor: pointer; min-width: 220px;">
|
||||||
|
<option value="">Toutes les régions</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="stat-cards">
|
<div class="stat-cards">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<header><p>Régulier — Moyenne</p></header>
|
<header><p>Régulier — Moyenne</p></header>
|
||||||
|
|
@ -418,6 +423,7 @@
|
||||||
let mapInitialized = false;
|
let mapInitialized = false;
|
||||||
let statsChart = null;
|
let statsChart = null;
|
||||||
let currentDays = 7;
|
let currentDays = 7;
|
||||||
|
let stationDeltas = {}; // address → {regular, super, diesel} pct change or null
|
||||||
|
|
||||||
function showPage(name) {
|
function showPage(name) {
|
||||||
document.getElementById('page-map').style.display = name === 'map' ? 'flex' : 'none';
|
document.getElementById('page-map').style.display = name === 'map' ? 'flex' : 'none';
|
||||||
|
|
@ -431,6 +437,7 @@
|
||||||
setTimeout(() => map.invalidateSize(), 50);
|
setTimeout(() => map.invalidateSize(), 50);
|
||||||
}
|
}
|
||||||
if (name === 'stats') {
|
if (name === 'stats') {
|
||||||
|
loadStatsRegions();
|
||||||
loadStats(currentDays);
|
loadStats(currentDays);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -485,12 +492,15 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch('/api/stations')
|
Promise.all([
|
||||||
.then(r => r.json())
|
fetch('/api/stations').then(r => r.json()),
|
||||||
.then(data => {
|
fetch('/api/station-deltas').then(r => r.json()).catch(() => ({})),
|
||||||
|
])
|
||||||
|
.then(([data, deltas]) => {
|
||||||
document.getElementById('loading').style.display = 'none';
|
document.getElementById('loading').style.display = 'none';
|
||||||
mapInitialized = true;
|
mapInitialized = true;
|
||||||
allStations = data.stations;
|
allStations = data.stations;
|
||||||
|
stationDeltas = deltas || {};
|
||||||
|
|
||||||
// Populate region dropdown from data
|
// Populate region dropdown from data
|
||||||
const regions = [...new Set(allStations.map(s => s.region).filter(Boolean))].sort();
|
const regions = [...new Set(allStations.map(s => s.region).filter(Boolean))].sort();
|
||||||
|
|
@ -634,10 +644,13 @@
|
||||||
});
|
});
|
||||||
const marker = L.marker([s.lat, s.lng], { icon });
|
const marker = L.marker([s.lat, s.lng], { icon });
|
||||||
marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel };
|
marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel };
|
||||||
let popup = `<strong>${s.name}</strong><br>${s.brand}<br>${s.address}<br>`;
|
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.address + ', Québec')}`;
|
||||||
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L`;
|
const d = stationDeltas[s.address] || {};
|
||||||
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L`;
|
const eh = d.elapsedHours != null ? d.elapsedHours : null;
|
||||||
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L`;
|
let popup = `<strong>${s.name}</strong><br>${brandBadgeHtml(s.brand)}<br><a href="${mapsUrl}" target="_blank" rel="noopener">${s.address}</a><br>`;
|
||||||
|
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`;
|
||||||
|
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`;
|
||||||
|
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`;
|
||||||
marker.bindPopup(popup);
|
marker.bindPopup(popup);
|
||||||
return marker;
|
return marker;
|
||||||
});
|
});
|
||||||
|
|
@ -664,7 +677,93 @@
|
||||||
return `rgb(${Math.round(c0[0]+(c1[0]-c0[0])*frac)},${Math.round(c0[1]+(c1[1]-c0[1])*frac)},${Math.round(c0[2]+(c1[2]-c0[2])*frac)})`;
|
return `rgb(${Math.round(c0[0]+(c1[0]-c0[0])*frac)},${Math.round(c0[1]+(c1[1]-c0[1])*frac)},${Math.round(c0[2]+(c1[2]-c0[2])*frac)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Brand colours ─────────────────────────────────────────
|
||||||
|
// Real brand identity colours where known; others get a
|
||||||
|
// deterministic colour picked from a curated distinct-hue palette.
|
||||||
|
const BRAND_COLORS = {
|
||||||
|
'ultramar': '#0057a8',
|
||||||
|
'petro-canada': '#e4002b',
|
||||||
|
'esso': '#003087',
|
||||||
|
'shell': '#dd1d21',
|
||||||
|
'pioneer': '#f47920',
|
||||||
|
'couche-tard': '#c8102e',
|
||||||
|
'circle k': '#c8102e',
|
||||||
|
'crevier': '#e3000f',
|
||||||
|
'irving': '#006747',
|
||||||
|
'gilbert': '#00796b',
|
||||||
|
'sonic': '#7b2d8b',
|
||||||
|
'dépanneur': '#475569',
|
||||||
|
'indépendant': '#334155',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 12-colour fallback palette for unknown brands (evenly spaced hues,
|
||||||
|
// all with sufficient contrast against white).
|
||||||
|
const FALLBACK_PALETTE = [
|
||||||
|
'#b45309','#0369a1','#065f46','#7c3aed',
|
||||||
|
'#be185d','#0f766e','#b91c1c','#1d4ed8',
|
||||||
|
'#15803d','#a21caf','#c2410c','#0e7490',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Return the background hex colour for a brand name.
|
||||||
|
function brandColor(brand) {
|
||||||
|
const key = (brand || '').toLowerCase().trim();
|
||||||
|
if (BRAND_COLORS[key]) return BRAND_COLORS[key];
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff;
|
||||||
|
return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// WCAG relative luminance of a hex colour string.
|
||||||
|
function luminance(hex) {
|
||||||
|
const r = parseInt(hex.slice(1,3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3,5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5,7), 16) / 255;
|
||||||
|
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick white or black foreground for best contrast against bg.
|
||||||
|
function contrastFg(bgHex) {
|
||||||
|
const L = luminance(bgHex);
|
||||||
|
const onWhite = (L + 0.05) / (0.05); // contrast vs white
|
||||||
|
const onBlack = (1.05) / (L + 0.05); // contrast vs black
|
||||||
|
return onBlack > onWhite ? '#ffffff' : '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandBadgeHtml(brand) {
|
||||||
|
if (!brand) return '';
|
||||||
|
const bg = brandColor(brand);
|
||||||
|
const fg = contrastFg(bg);
|
||||||
|
return `<span class="badge" style="--primary:${bg};--primary-foreground:${fg}">${brand}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns an inline HTML snippet showing the 24h price change.
|
||||||
|
// pct is a number (percentage) or null/undefined when no history is available.
|
||||||
|
function priceDeltaHtml(pct, elapsedHours) {
|
||||||
|
if (pct == null) return '';
|
||||||
|
if (Math.abs(pct) < 0.05) return ''; // treat sub-0.05% as unchanged
|
||||||
|
const up = pct > 0;
|
||||||
|
const color = up ? '#dc2626' : '#16a34a'; // red up, green down
|
||||||
|
const arrow = up
|
||||||
|
? '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,8 5,2 8,8"/></svg>'
|
||||||
|
: '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,2 5,8 8,2"/></svg>';
|
||||||
|
const sign = up ? '+' : '';
|
||||||
|
let timeLabel;
|
||||||
|
if (elapsedHours == null) {
|
||||||
|
timeLabel = '';
|
||||||
|
} else if (elapsedHours < 1) {
|
||||||
|
timeLabel = ' (< 1h)';
|
||||||
|
} else {
|
||||||
|
const h = Math.min(24, Math.round(elapsedHours));
|
||||||
|
timeLabel = ` (${h}h)`;
|
||||||
|
}
|
||||||
|
return ` <span style="color:${color};font-size:11px;white-space:nowrap;">${arrow}${sign}${pct.toFixed(2)}%${timeLabel}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Statistics page ───────────────────────────────────────
|
// ── Statistics page ───────────────────────────────────────
|
||||||
|
let currentStatsRegion = '';
|
||||||
|
let statsRegionsLoaded = false;
|
||||||
|
|
||||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
|
@ -674,8 +773,33 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function loadStatsRegions() {
|
||||||
|
if (statsRegionsLoaded) return;
|
||||||
|
fetch('/api/regions')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(regions => {
|
||||||
|
statsRegionsLoaded = true;
|
||||||
|
const sel = document.getElementById('stats-region-select');
|
||||||
|
regions.forEach(region => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = region;
|
||||||
|
opt.textContent = region;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => console.error('regions error:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('stats-region-select').addEventListener('change', function() {
|
||||||
|
currentStatsRegion = this.value;
|
||||||
|
loadStats(currentDays);
|
||||||
|
});
|
||||||
|
|
||||||
function loadStats(days) {
|
function loadStats(days) {
|
||||||
const url = '/api/stats?days=' + days;
|
const region = currentStatsRegion;
|
||||||
|
const url = region
|
||||||
|
? '/api/stats/region?region=' + encodeURIComponent(region) + '&days=' + days
|
||||||
|
: '/api/stats?days=' + days;
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue