new stack

This commit is contained in:
Polen 2026-04-07 21:42:30 -04:00
parent 6922e9d63b
commit ac9737b125
10 changed files with 1476 additions and 1221 deletions

777
main.go
View file

@ -6,11 +6,13 @@ import (
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
@ -22,6 +24,9 @@ import (
//go:embed static/*
var staticFiles embed.FS
//go:embed templates/*
var templateFiles embed.FS
const (
geojsonURL = "https://regieessencequebec.ca/stations.geojson.gz"
defaultPort = "8080"
@ -85,6 +90,9 @@ var (
var db *sql.DB
// tmpl is the parsed template set, loaded once at startup.
var tmpl *template.Template
func main() {
port := os.Getenv("PORT")
if port == "" {
@ -103,6 +111,11 @@ func main() {
}
defer db.Close()
tmpl, err = loadTemplates()
if err != nil {
log.Fatalf("templates: %v", err)
}
// Initial fetch, then background poll every 5 minutes.
go poller()
@ -111,20 +124,533 @@ func main() {
log.Fatalf("static files: %v", err)
}
// HTML page routes
http.HandleFunc("/", handleRoot)
http.HandleFunc("/map", handleMapPage)
http.HandleFunc("/stats", handleStatsPage)
http.HandleFunc("/stats/content", handleStatsContent)
// JSON API routes (kept for backwards-compatibility)
http.HandleFunc("/api/stations", handleStations)
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)))
// Static assets (map.js, stats.js, etc.)
http.Handle("/map.js", http.FileServer(http.FS(staticSub)))
http.Handle("/stats.js", http.FileServer(http.FS(staticSub)))
log.Printf("Listening on http://localhost:%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
// loadTemplates parses all templates from the embedded templates/ directory.
// It registers helper functions used in templates.
func loadTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
"fmtPrice": func(v float64) string {
if v <= 0 {
return "—"
}
return fmt.Sprintf("%.1f", v)
},
}
t, err := template.New("").Funcs(funcMap).ParseFS(templateFiles, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return t, nil
}
// renderTemplate executes a named template with data, writing the result to w.
// On error it falls back to a plain HTTP 500.
func renderTemplate(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template %q: %v", name, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
// isHTMX returns true when the request was issued by htmx.
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// ── Page data structs ──────────────────────────────────────────────────────
// mapPageData holds everything needed to render the map page.
type mapPageData struct {
Page string
StationsJSON template.JS
DeltasJSON template.JS
Regions []string
LastUpdated string
StationCount int
}
// statsPageData holds everything needed to render the stats page shell.
type statsPageData struct {
Page string
Regions []string
}
// historyRow is one row in the history table, pre-formatted for the template.
type historyRow struct {
Date string
RegularAvg string
RegularMin string
RegularMax string
SuperAvg string
DieselAvg string
StationCount int
}
// statsContentData holds everything needed to render the stats-content fragment.
type statsContentData struct {
Days int
Snapshots []Snapshot
SnapshotsJSON template.JS
Last Snapshot
HistoryRows []historyRow
}
// ── HTML page handlers ────────────────────────────────────────────────────
// handleRoot redirects bare "/" to the map page.
func handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/map", http.StatusSeeOther)
}
// handleMapPage renders the full map page (or just the content block for htmx).
func handleMapPage(w http.ResponseWriter, r *http.Request) {
cacheMu.RLock()
resp := cachedResp
cacheMu.RUnlock()
if resp == nil {
// Data not yet ready; render layout with a loading message.
renderTemplate(w, "layout.html", mapPageData{Page: "map"})
return
}
// Build sorted unique region list from in-memory station data.
regionSet := map[string]struct{}{}
for _, s := range resp.Stations {
if s.Region != "" {
regionSet[s.Region] = struct{}{}
}
}
regions := make([]string, 0, len(regionSet))
for r := range regionSet {
regions = append(regions, r)
}
sort.Strings(regions)
// Encode station and delta data for inline script injection.
stationsJSON, err := json.Marshal(resp.Stations)
if err != nil {
http.Error(w, "encoding stations", http.StatusInternalServerError)
return
}
deltas, err := buildDeltas()
if err != nil {
log.Printf("build deltas: %v", err)
deltas = map[string]StationDelta{}
}
deltasJSON, err := json.Marshal(deltas)
if err != nil {
http.Error(w, "encoding deltas", http.StatusInternalServerError)
return
}
lastUpdated := ""
if resp.LastUpdated != "" {
if t, err := time.Parse(time.RFC3339, resp.LastUpdated); err == nil {
lastUpdated = "Dernière mise à jour: " + t.In(time.FixedZone("ET", -4*3600)).
Format("2 January 2006, 15:04")
}
}
data := mapPageData{
Page: "map",
StationsJSON: template.JS(stationsJSON),
DeltasJSON: template.JS(deltasJSON),
Regions: regions,
LastUpdated: lastUpdated,
StationCount: len(resp.Stations),
}
if isHTMX(r) {
// Return only the page fragment (content + inline scripts), not the full layout.
renderTemplate(w, "map-content", data)
return
}
renderTemplate(w, "layout.html", data)
}
// handleStatsPage renders the full stats page shell (or just the content block for htmx).
func handleStatsPage(w http.ResponseWriter, r *http.Request) {
regions, err := queryRegions()
if err != nil {
log.Printf("query regions: %v", err)
regions = []string{}
}
data := statsPageData{
Page: "stats",
Regions: regions,
}
if isHTMX(r) {
renderTemplate(w, "stats-content-shell", data)
return
}
renderTemplate(w, "layout.html", data)
}
// handleStatsContent renders only the stats-content fragment (cards + chart + table).
// This is the htmx target for region and range changes.
func handleStatsContent(w http.ResponseWriter, r *http.Request) {
daysStr := r.URL.Query().Get("days")
days := 7
if daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d >= 0 {
days = d
}
}
region := r.URL.Query().Get("region")
snapshots, err := querySnapshots(region, daysStr)
if err != nil {
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
return
}
var last Snapshot
if len(snapshots) > 0 {
last = snapshots[len(snapshots)-1]
}
// Build history rows (last 10, most-recent first).
rows := snapshots
if len(rows) > 10 {
rows = rows[len(rows)-10:]
}
history := make([]historyRow, 0, len(rows))
for i := len(rows) - 1; i >= 0; i-- {
s := rows[i]
d := "—"
if t, err := time.Parse(time.RFC3339, s.GeneratedAt); err == nil {
d = t.In(time.FixedZone("ET", -4*3600)).
Format("2 Jan 2006, 15:04")
}
history = append(history, historyRow{
Date: d,
RegularAvg: fmtPrice(s.RegularAvg),
RegularMin: fmtPrice(s.RegularMin),
RegularMax: fmtPrice(s.RegularMax),
SuperAvg: fmtPrice(s.SuperAvg),
DieselAvg: fmtPrice(s.DieselAvg),
StationCount: s.StationCount,
})
}
snapshotsJSON, err := json.Marshal(snapshots)
if err != nil {
http.Error(w, "encoding snapshots", http.StatusInternalServerError)
return
}
renderTemplate(w, "stats-content.html", statsContentData{
Days: days,
Snapshots: snapshots,
SnapshotsJSON: template.JS(snapshotsJSON),
Last: last,
HistoryRows: history,
})
}
// fmtPrice formats a price value for display; returns "—" for zero/negative.
func fmtPrice(v float64) string {
if v <= 0 {
return "—"
}
return fmt.Sprintf("%.1f¢", v)
}
// ── Shared DB helpers ─────────────────────────────────────────────────────
// queryRegions returns sorted distinct region names.
func queryRegions() ([]string, error) {
rows, err := db.Query(`SELECT DISTINCT region FROM region_snapshots ORDER BY region ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var regions []string
for rows.Next() {
var region string
if err := rows.Scan(&region); err != nil {
return nil, err
}
regions = append(regions, region)
}
return regions, nil
}
// querySnapshots fetches time-series snapshots for the given region (empty = global)
// and day window (daysStr "0" or "" means all).
func querySnapshots(region, daysStr string) ([]Snapshot, error) {
days := 7
if daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d >= 0 {
days = d
}
}
allTime := daysStr == "0"
var (
sqlRows *sql.Rows
err error
)
if region != "" {
if allTime {
sqlRows, 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)
sqlRows, 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 {
return nil, err
}
defer sqlRows.Close()
var snaps []Snapshot
for sqlRows.Next() {
var s Snapshot
if err := sqlRows.Scan(&s.GeneratedAt, &s.RegularAvg, &s.RegularMin, &s.RegularMax,
&s.SuperAvg, &s.DieselAvg, &s.StationCount); err != nil {
return nil, err
}
snaps = append(snaps, s)
}
return snaps, nil
}
// Global snapshots
if allTime {
sqlRows, err = db.Query(`
SELECT generated_at, fetched_at, regular_avg, regular_min, regular_max,
super_avg, diesel_avg, station_count
FROM snapshots
ORDER BY generated_at ASC
`)
} else {
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
sqlRows, err = db.Query(`
SELECT generated_at, fetched_at, regular_avg, regular_min, regular_max,
super_avg, diesel_avg, station_count
FROM snapshots
WHERE generated_at >= ?
ORDER BY generated_at ASC
`, since)
}
if err != nil {
return nil, err
}
defer sqlRows.Close()
var snaps []Snapshot
for sqlRows.Next() {
var s Snapshot
if err := sqlRows.Scan(&s.GeneratedAt, &s.FetchedAt, &s.RegularAvg, &s.RegularMin, &s.RegularMax,
&s.SuperAvg, &s.DieselAvg, &s.StationCount); err != nil {
return nil, err
}
snaps = append(snaps, s)
}
return snaps, nil
}
// buildDeltas computes per-station price deltas from the station_prices table.
func buildDeltas() (map[string]StationDelta, error) {
since := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339)
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 {
return nil, err
}
defer rows.Close()
type priceRow struct {
generatedAt string
regular float64
super float64
diesel float64
}
type addrRows struct {
old *priceRow
cur *priceRow
}
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 {
return nil, err
}
ar, ok := byAddr[addr]
if !ok {
ar = &addrRows{}
byAddr[addr] = ar
}
row := &priceRow{generatedAt: genAt, regular: reg, super: sup, diesel: die}
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 {
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(),
}
}
return result, nil
}
// ── JSON API handlers (kept for backwards-compatibility) ──────────────────
func handleStations(w http.ResponseWriter, r *http.Request) {
cacheMu.RLock()
resp := cachedResp
cacheMu.RUnlock()
if resp == nil {
http.Error(w, "data not yet available, please retry shortly", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
json.NewEncoder(w).Encode(resp)
}
func handleStats(w http.ResponseWriter, r *http.Request) {
daysStr := r.URL.Query().Get("days")
snapshots, err := querySnapshots("", daysStr)
if err != nil {
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
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) {
regions, err := queryRegions()
if err != nil {
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
return
}
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")
snapshots, err := querySnapshots(region, daysStr)
if err != nil {
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
return
}
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.
func handleStationDeltas(w http.ResponseWriter, r *http.Request) {
result, err := buildDeltas()
if err != nil {
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
json.NewEncoder(w).Encode(result)
}
// ── DB init ───────────────────────────────────────────────────────────────
// initDB opens (or creates) the SQLite database and ensures the schema exists.
func initDB(path string) (*sql.DB, error) {
d, err := sql.Open("sqlite", path)
d, err := sql.Open("sqlite", path+"?_busy_timeout=5000")
if err != nil {
return nil, err
}
@ -176,7 +702,6 @@ func initDB(path string) (*sql.DB, error) {
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)
@ -188,6 +713,8 @@ func initDB(path string) (*sql.DB, error) {
return d, nil
}
// ── Poller ────────────────────────────────────────────────────────────────
// poller fetches immediately then every pollInterval.
func poller() {
fetchAndStore()
@ -404,245 +931,7 @@ func computeRegionSnapshots(resp *StationsResponse) []RegionSnapshot {
return snaps
}
func handleStations(w http.ResponseWriter, r *http.Request) {
cacheMu.RLock()
resp := cachedResp
cacheMu.RUnlock()
if resp == nil {
http.Error(w, "data not yet available, please retry shortly", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
json.NewEncoder(w).Encode(resp)
}
func handleStats(w http.ResponseWriter, r *http.Request) {
daysStr := r.URL.Query().Get("days")
days := 7
if daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
days = d
}
}
// days=0 means all data
var rows *sql.Rows
var err error
if daysStr == "0" {
rows, err = db.Query(`
SELECT generated_at, fetched_at, regular_avg, regular_min, regular_max,
super_avg, diesel_avg, station_count
FROM snapshots
ORDER BY generated_at ASC
`)
} else {
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
rows, err = db.Query(`
SELECT generated_at, fetched_at, regular_avg, regular_min, regular_max,
super_avg, diesel_avg, station_count
FROM snapshots
WHERE generated_at >= ?
ORDER BY generated_at ASC
`, 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.FetchedAt, &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})
}
// 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)
}
// ── Upstream fetch ────────────────────────────────────────────────────────
func fetchAndParse() (*StationsResponse, error) {
log.Println("Fetching GeoJSON data from upstream...")
@ -665,7 +954,6 @@ func fetchAndParse() (*StationsResponse, error) {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
// Handle gzip if the response is compressed
var reader io.Reader = resp.Body
if resp.Header.Get("Content-Encoding") == "gzip" || strings.HasSuffix(geojsonURL, ".gz") {
gz, err := gzip.NewReader(resp.Body)
@ -681,7 +969,6 @@ func fetchAndParse() (*StationsResponse, error) {
return nil, fmt.Errorf("decoding geojson: %w", err)
}
// Parse features
var features []struct {
Geometry struct {
Coordinates [2]float64 `json:"coordinates"`
@ -767,7 +1054,7 @@ func parsePrice(s string) float64 {
return 0
}
s = strings.TrimSuffix(s, "¢")
s = strings.TrimSuffix(s, "\u00a2") // cent sign
s = strings.TrimSuffix(s, "\u00a2")
var v float64
_, err := fmt.Sscanf(s, "%f", &v)