smol change

This commit is contained in:
Polen 2026-04-04 14:18:29 -04:00
parent cdec0834e9
commit 6922e9d63b
3 changed files with 668 additions and 11 deletions

197
AGENTS.md Normal file
View 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
View file

@ -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(&region); 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, &reg, &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...")

View file

@ -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 => {