1107 lines
29 KiB
Go
1107 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFiles embed.FS
|
|
|
|
//go:embed templates/*
|
|
var templateFiles embed.FS
|
|
|
|
const (
|
|
geojsonURL = "https://regieessencequebec.ca/stations.geojson.gz"
|
|
defaultPort = "8080"
|
|
pollInterval = 5 * time.Minute
|
|
fetchCooldown = 1 * time.Minute
|
|
)
|
|
|
|
// GeoJSON structures matching the upstream format.
|
|
type GeoJSONResponse struct {
|
|
Type string `json:"type"`
|
|
Metadata *GeoJSONMeta `json:"metadata,omitempty"`
|
|
Features json.RawMessage `json:"features"`
|
|
}
|
|
|
|
type GeoJSONMeta struct {
|
|
GeneratedAt string `json:"generated_at"`
|
|
ExcelURL string `json:"excel_url"`
|
|
TotalStations int `json:"total_stations"`
|
|
ExcelSizeBytes int `json:"excel_size_bytes"`
|
|
}
|
|
|
|
// Station is our simplified JSON shape for the frontend.
|
|
type Station struct {
|
|
Name string `json:"name"`
|
|
Brand string `json:"brand"`
|
|
Address string `json:"address"`
|
|
Region string `json:"region"`
|
|
PostalCode string `json:"postalCode"`
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
Regular float64 `json:"regular"`
|
|
Super float64 `json:"super"`
|
|
Diesel float64 `json:"diesel"`
|
|
}
|
|
|
|
type StationsResponse struct {
|
|
LastUpdated string `json:"lastUpdated"`
|
|
Stations []Station `json:"stations"`
|
|
}
|
|
|
|
// Snapshot holds the aggregated statistics for one fetch.
|
|
type Snapshot struct {
|
|
GeneratedAt string `json:"generatedAt"`
|
|
FetchedAt string `json:"fetchedAt"`
|
|
RegularAvg float64 `json:"regularAvg"`
|
|
RegularMin float64 `json:"regularMin"`
|
|
RegularMax float64 `json:"regularMax"`
|
|
SuperAvg float64 `json:"superAvg"`
|
|
DieselAvg float64 `json:"dieselAvg"`
|
|
StationCount int `json:"stationCount"`
|
|
}
|
|
|
|
type StatsResponse struct {
|
|
Snapshots []Snapshot `json:"snapshots"`
|
|
}
|
|
|
|
// In-memory cache
|
|
var (
|
|
cacheMu sync.RWMutex
|
|
cachedResp *StationsResponse
|
|
)
|
|
|
|
// Fetch cooldown state: prevents request-triggered fetches from
|
|
// firing more often than fetchCooldown.
|
|
var (
|
|
fetchMu sync.Mutex
|
|
lastFetchTime time.Time
|
|
)
|
|
|
|
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 == "" {
|
|
port = defaultPort
|
|
}
|
|
|
|
dbPath := os.Getenv("ESSENCE_DB")
|
|
if dbPath == "" {
|
|
dbPath = "./essence.db"
|
|
}
|
|
|
|
var err error
|
|
db, err = initDB(dbPath)
|
|
if err != nil {
|
|
log.Fatalf("db init: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
tmpl, err = loadTemplates()
|
|
if err != nil {
|
|
log.Fatalf("templates: %v", err)
|
|
}
|
|
|
|
// Initial fetch, then background poll every 5 minutes.
|
|
go poller()
|
|
|
|
staticSub, err := fs.Sub(staticFiles, "static")
|
|
if err != nil {
|
|
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)
|
|
|
|
// Static assets (style.css, map.js, stats.js, etc.)
|
|
http.Handle("/style.css", http.FileServer(http.FS(staticSub)))
|
|
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) {
|
|
triggerFetch()
|
|
|
|
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+"?_busy_timeout=5000")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = d.Exec(`
|
|
CREATE TABLE IF NOT EXISTS snapshots (
|
|
generated_at TEXT PRIMARY KEY,
|
|
fetched_at TEXT NOT NULL,
|
|
regular_avg REAL,
|
|
regular_min REAL,
|
|
regular_max REAL,
|
|
super_avg REAL,
|
|
diesel_avg REAL,
|
|
station_count INTEGER
|
|
)
|
|
`)
|
|
if err != nil {
|
|
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)
|
|
}
|
|
|
|
_, 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
|
|
}
|
|
|
|
// ── Poller ────────────────────────────────────────────────────────────────
|
|
|
|
// poller fetches immediately then every pollInterval.
|
|
func poller() {
|
|
safeFetchAndStore()
|
|
ticker := time.NewTicker(pollInterval)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
safeFetchAndStore()
|
|
}
|
|
}
|
|
|
|
// safeFetchAndStore calls fetchAndStore and recovers from any panic so the
|
|
// poller goroutine stays alive.
|
|
func safeFetchAndStore() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("panic in fetchAndStore: %v", r)
|
|
}
|
|
}()
|
|
fetchAndStore()
|
|
}
|
|
|
|
// triggerFetch kicks off a background fetchAndStore if at least fetchCooldown
|
|
// has elapsed since the last fetch attempt. It returns immediately and never
|
|
// blocks the caller.
|
|
func triggerFetch() {
|
|
fetchMu.Lock()
|
|
if time.Since(lastFetchTime) < fetchCooldown {
|
|
fetchMu.Unlock()
|
|
return
|
|
}
|
|
lastFetchTime = time.Now()
|
|
fetchMu.Unlock()
|
|
go safeFetchAndStore()
|
|
}
|
|
|
|
// fetchAndStore fetches upstream data, updates the in-memory cache, and
|
|
// persists a snapshot to SQLite if the data has a new generated_at value.
|
|
func fetchAndStore() {
|
|
// Reset cooldown so poller-initiated fetches also prevent redundant
|
|
// request-triggered fetches.
|
|
fetchMu.Lock()
|
|
lastFetchTime = time.Now()
|
|
fetchMu.Unlock()
|
|
|
|
resp, err := fetchAndParse()
|
|
if err != nil {
|
|
log.Printf("fetch error: %v", err)
|
|
return
|
|
}
|
|
|
|
cacheMu.Lock()
|
|
cachedResp = resp
|
|
cacheMu.Unlock()
|
|
|
|
if resp.LastUpdated == "" {
|
|
return
|
|
}
|
|
|
|
// Compute and persist global aggregate.
|
|
snap := computeSnapshot(resp)
|
|
|
|
_, err = db.Exec(`
|
|
INSERT OR IGNORE INTO snapshots
|
|
(generated_at, fetched_at, regular_avg, regular_min, regular_max, super_avg, diesel_avg, station_count)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
snap.GeneratedAt,
|
|
snap.FetchedAt,
|
|
snap.RegularAvg,
|
|
snap.RegularMin,
|
|
snap.RegularMax,
|
|
snap.SuperAvg,
|
|
snap.DieselAvg,
|
|
snap.StationCount,
|
|
)
|
|
if err != nil {
|
|
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.
|
|
func computeSnapshot(resp *StationsResponse) Snapshot {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
snap := Snapshot{
|
|
GeneratedAt: resp.LastUpdated,
|
|
FetchedAt: now,
|
|
}
|
|
|
|
var regularSum, superSum, dieselSum float64
|
|
var regularCount, superCount, dieselCount int
|
|
snap.RegularMin = 1<<53 - 1
|
|
snap.RegularMax = 0
|
|
|
|
for _, s := range resp.Stations {
|
|
if s.Regular > 0 {
|
|
regularSum += s.Regular
|
|
regularCount++
|
|
if s.Regular < snap.RegularMin {
|
|
snap.RegularMin = s.Regular
|
|
}
|
|
if s.Regular > snap.RegularMax {
|
|
snap.RegularMax = s.Regular
|
|
}
|
|
}
|
|
if s.Super > 0 {
|
|
superSum += s.Super
|
|
superCount++
|
|
}
|
|
if s.Diesel > 0 {
|
|
dieselSum += s.Diesel
|
|
dieselCount++
|
|
}
|
|
}
|
|
|
|
snap.StationCount = len(resp.Stations)
|
|
if regularCount > 0 {
|
|
snap.RegularAvg = regularSum / float64(regularCount)
|
|
} else {
|
|
snap.RegularMin = 0
|
|
}
|
|
if superCount > 0 {
|
|
snap.SuperAvg = superSum / float64(superCount)
|
|
}
|
|
if dieselCount > 0 {
|
|
snap.DieselAvg = dieselSum / float64(dieselCount)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ── Upstream fetch ────────────────────────────────────────────────────────
|
|
|
|
func fetchAndParse() (*StationsResponse, error) {
|
|
log.Println("Fetching GeoJSON data from upstream...")
|
|
|
|
req, err := http.NewRequest("GET", geojsonURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
req.Header.Set("User-Agent", "essence-quebec-map/1.0")
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching data: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
|
|
var reader io.Reader = resp.Body
|
|
if resp.Header.Get("Content-Encoding") == "gzip" || strings.HasSuffix(geojsonURL, ".gz") {
|
|
gz, err := gzip.NewReader(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gzip reader: %w", err)
|
|
}
|
|
defer gz.Close()
|
|
reader = gz
|
|
}
|
|
|
|
var geojson GeoJSONResponse
|
|
if err := json.NewDecoder(reader).Decode(&geojson); err != nil {
|
|
return nil, fmt.Errorf("decoding geojson: %w", err)
|
|
}
|
|
|
|
var features []struct {
|
|
Geometry struct {
|
|
Coordinates [2]float64 `json:"coordinates"`
|
|
} `json:"geometry"`
|
|
Properties struct {
|
|
Name string `json:"Name"`
|
|
Brand string `json:"brand"`
|
|
Address string `json:"Address"`
|
|
PostalCode string `json:"PostalCode"`
|
|
Region string `json:"Region"`
|
|
Prices []struct {
|
|
GasType string `json:"GasType"`
|
|
Price *string `json:"Price"`
|
|
IsAvailable bool `json:"IsAvailable"`
|
|
} `json:"Prices"`
|
|
} `json:"properties"`
|
|
}
|
|
|
|
if err := json.Unmarshal(geojson.Features, &features); err != nil {
|
|
return nil, fmt.Errorf("parsing features: %w", err)
|
|
}
|
|
|
|
var stations []Station
|
|
for _, f := range features {
|
|
lng := f.Geometry.Coordinates[0]
|
|
lat := f.Geometry.Coordinates[1]
|
|
if lat == 0 && lng == 0 {
|
|
continue
|
|
}
|
|
|
|
s := Station{
|
|
Name: f.Properties.Name,
|
|
Brand: f.Properties.Brand,
|
|
Address: f.Properties.Address,
|
|
Region: f.Properties.Region,
|
|
PostalCode: f.Properties.PostalCode,
|
|
Lat: lat,
|
|
Lng: lng,
|
|
}
|
|
|
|
for _, p := range f.Properties.Prices {
|
|
if p.Price == nil || !p.IsAvailable {
|
|
continue
|
|
}
|
|
price := parsePrice(*p.Price)
|
|
if price <= 0 {
|
|
continue
|
|
}
|
|
switch p.GasType {
|
|
case "Régulier":
|
|
s.Regular = price
|
|
case "Super":
|
|
s.Super = price
|
|
case "Diesel":
|
|
s.Diesel = price
|
|
}
|
|
}
|
|
|
|
if s.Regular <= 0 {
|
|
continue
|
|
}
|
|
|
|
stations = append(stations, s)
|
|
}
|
|
|
|
lastUpdated := ""
|
|
if geojson.Metadata != nil && geojson.Metadata.GeneratedAt != "" {
|
|
lastUpdated = geojson.Metadata.GeneratedAt
|
|
}
|
|
|
|
log.Printf("Parsed %d stations (last updated: %s)", len(stations), lastUpdated)
|
|
|
|
return &StationsResponse{
|
|
LastUpdated: lastUpdated,
|
|
Stations: stations,
|
|
}, nil
|
|
}
|
|
|
|
// parsePrice converts "190.9¢" to 190.9
|
|
func parsePrice(s string) float64 {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" || s == "N/D" {
|
|
return 0
|
|
}
|
|
s = strings.TrimSuffix(s, "¢")
|
|
s = strings.TrimSuffix(s, "\u00a2")
|
|
|
|
var v float64
|
|
_, err := fmt.Sscanf(s, "%f", &v)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|