new stack
This commit is contained in:
parent
6922e9d63b
commit
ac9737b125
10 changed files with 1476 additions and 1221 deletions
777
main.go
777
main.go
|
|
@ -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(®ion); 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, ®, &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(®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)
|
||||
}
|
||||
// ── 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue