commit 46c024e8bd720977b76521a36ee1fa3c9395e242 Author: Polen Date: Thu Apr 2 09:50:45 2026 -0400 initial project setup 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)

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