From 46c024e8bd720977b76521a36ee1fa3c9395e242 Mon Sep 17 00:00:00 2001 From: Polen Date: Thu, 2 Apr 2026 09:50:45 -0400 Subject: [PATCH] initial project setup --- .envrc | 1 + .gitignore | 2 + flake.lock | 61 +++++++++++ flake.nix | 31 ++++++ go.mod | 16 +++ go.sum | 28 +++++ main.go | 171 ++++++++++++++++++++++++++++++ static/index.html | 264 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 574 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 static/index.html diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c9e204 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/essence +/result diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..03cb250 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1d65345 --- /dev/null +++ b/flake.nix @@ -0,0 +1,31 @@ +{ + description = "Essence Quebec - Gas price heatmap"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go + gopls + gotools + ]; + }; + + packages.default = pkgs.buildGoModule { + pname = "essence"; + version = "0.1.0"; + src = ./.; + vendorHash = null; # Will need updating after go mod tidy + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c876b8d --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/polen/essence + +go 1.26.1 + +require github.com/xuri/excelize/v2 v2.10.1 + +require ( + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cff6dd8 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..0a08768 --- /dev/null +++ b/main.go @@ -0,0 +1,171 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" +) + +//go:embed static/* +var staticFiles embed.FS + +const ( + dataURL = "https://regieessencequebec.ca/data/stations-20260402132004.xlsx" + defaultPort = "8080" +) + +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"` +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = defaultPort + } + + staticSub, err := fs.Sub(staticFiles, "static") + if err != nil { + log.Fatalf("static files: %v", err) + } + + http.HandleFunc("/api/stations", handleStations) + http.Handle("/", http.FileServer(http.FS(staticSub))) + + log.Printf("Listening on http://localhost:%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +func handleStations(w http.ResponseWriter, r *http.Request) { + stations, err := fetchAndParse() + if err != nil { + http.Error(w, fmt.Sprintf("error: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") + json.NewEncoder(w).Encode(stations) +} + +func fetchAndParse() ([]Station, error) { + log.Println("Fetching Excel data...") + resp, err := http.Get(dataURL) + 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) + } + + tmp, err := os.CreateTemp("", "stations-*.xlsx") + if err != nil { + return nil, fmt.Errorf("creating temp file: %w", err) + } + defer os.Remove(tmp.Name()) + + if _, err := io.Copy(tmp, resp.Body); err != nil { + tmp.Close() + return nil, fmt.Errorf("writing temp file: %w", err) + } + tmp.Close() + + f, err := excelize.OpenFile(tmp.Name()) + if err != nil { + return nil, fmt.Errorf("opening excel: %w", err) + } + defer f.Close() + + sheets := f.GetSheetList() + if len(sheets) == 0 { + return nil, fmt.Errorf("no sheets found") + } + + rows, err := f.GetRows(sheets[0]) + if err != nil { + return nil, fmt.Errorf("reading rows: %w", err) + } + + if len(rows) < 2 { + return nil, fmt.Errorf("not enough rows") + } + + var stations []Station + for _, row := range rows[1:] { + if len(row) < 8 { + continue + } + + lat, err := strconv.ParseFloat(row[5], 64) + if err != nil { + continue + } + lng, err := strconv.ParseFloat(row[6], 64) + if err != nil { + continue + } + + regular := parsePrice(row[7]) + if regular <= 0 { + continue + } + + s := Station{ + Name: row[0], + Brand: row[1], + Address: row[2], + Region: row[3], + PostalCode: row[4], + Lat: lat, + Lng: lng, + Regular: regular, + } + + if len(row) > 8 { + s.Super = parsePrice(row[8]) + } + if len(row) > 9 { + s.Diesel = parsePrice(row[9]) + } + + stations = append(stations, s) + } + + log.Printf("Parsed %d stations", len(stations)) + return 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") // cent sign + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return v +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..457c1f0 --- /dev/null +++ b/static/index.html @@ -0,0 +1,264 @@ + + + + + + Essence Québec - Carte des prix + + + + +
Chargement des données...
+
+ +
+ + +
+ +
+ + +
+ - + - +
+
+
+ +
+

Prix régulier (¢/L)

+
+
+ - + - +
+
+
+ + + + + +