From ac9737b1253a2a6d2b1470a465df2e4c08668762 Mon Sep 17 00:00:00 2001 From: Polen Date: Tue, 7 Apr 2026 21:42:30 -0400 Subject: [PATCH] new stack --- AGENTS.md | 23 +- essence_new | Bin 0 -> 18976974 bytes main.go | 777 +++++++++++++++++++--------- static/index.html | 969 ----------------------------------- static/map.js | 287 +++++++++++ static/stats.js | 117 +++++ templates/layout.html | 316 ++++++++++++ templates/map.html | 68 +++ templates/stats-content.html | 106 ++++ templates/stats.html | 34 ++ 10 files changed, 1476 insertions(+), 1221 deletions(-) create mode 100755 essence_new delete mode 100644 static/index.html create mode 100644 static/map.js create mode 100644 static/stats.js create mode 100644 templates/layout.html create mode 100644 templates/map.html create mode 100644 templates/stats-content.html create mode 100644 templates/stats.html diff --git a/AGENTS.md b/AGENTS.md index 0849baf..c8dc345 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,16 @@ Guidance for agentic coding agents working in this repository. ## Project Overview -**Essence** is a Quebec gas price heatmap — a minimal, self-contained web application with two source files: +**Essence** is a Quebec gas price heatmap — a minimal web application with the following source files: -- `main.go` — the entire Go backend (HTTP server, SQLite persistence, upstream polling) -- `static/index.html` — the entire frontend (Leaflet map, Chart.js charts, vanilla JS) +- `main.go` — the entire Go backend (HTTP server, SQLite persistence, upstream polling, HTML template rendering) +- `templates/` — Go `html/template` files rendered server-side + - `layout.html` — base layout (head, nav, htmx script, oat CSS); defines `content` and `scripts` blocks + - `map.html` — map page content + scripts blocks + - `stats.html` — stats page shell (region dropdown, `#stats-content` target div) + - `stats-content.html` — stats fragment (cards, Chart.js canvas, history table); swapped by htmx on region/range changes +- `static/map.js` — Leaflet map initialization, marker rendering, cluster logic, controls wiring +- `static/stats.js` — Chart.js initialization; re-runs after htmx swaps `#stats-content` Keep this architecture. Do not split `main.go` into multiple files or introduce a frontend build step unless explicitly asked. @@ -179,19 +185,22 @@ type Station struct { ... } func poller() { ... } ``` -## Frontend Code Style (`static/index.html`) +## Frontend Code Style -- **No framework, no build step.** Plain HTML, CSS, and JavaScript in a single file. -- **Inline** ` - - - - -
-
- Chargement des données... -
- -
- - -
- Essence QC - -
- - -
- - -
-
- -
-
- - - -
- -
-
- - - -
-
- ? -
- Prix affiché sur chaque groupe de stations :

- Min — prix le plus bas du groupe
- Moy — prix moyen du groupe
- Max — prix le plus élevé du groupe

- La couleur reflète également la valeur affichée. -
-
-
- - -
- - - - -
-
-
- -
- -
-

Prix régulier (¢/L)

-
-
- - - - -
-
-
-
- - -
-

Statistiques des prix

- - -
- -
-
-
-

Régulier — Moyenne

-

-

¢/L

-
-
-

Régulier — Min

-

-

¢/L

-
-
-

Régulier — Max

-

-

¢/L

-
-
-

Super — Moyenne

-

-

¢/L

-
-
-

Diesel — Moyenne

-

-

¢/L

-
-
-

Stations

-

-

dernière mise à jour

-
-
- - -
-

Évolution des prix

-

Prix moyen par carburant (¢/L)

-
- - - -
- - -
- - -
-

Historique récent

-

10 dernières mises à jour

-
- - - - - - - - - - - - - - - -
DateRégulier moy.Régulier minRégulier maxSuper moy.Diesel moy.Stations
Chargement...
-
-
-
- -
-
- - - - - - - - diff --git a/static/map.js b/static/map.js new file mode 100644 index 0000000..fd79339 --- /dev/null +++ b/static/map.js @@ -0,0 +1,287 @@ +// map.js — Leaflet map logic for Essence QC +// Reads window.__stations and window.__deltas injected by the server. + +(function () { + 'use strict'; + + let currentFuel = 'regular'; + let currentRegion = ''; + let clusterMode = 'avg'; // 'min' | 'avg' | 'max' + let allStations = window.__stations || []; + let allMarkers = []; + let visibleSet = new Set(); + let minPrice = 0, maxPrice = 300; + let sliderTimer = null; + const stationDeltas = window.__deltas || {}; + + // ── Map setup (deferred to init section below) ───────────── + let map; + let clusterGroup; + + // ── Colour helpers ───────────────────────────────────────── + function priceColor(price, min, max) { + const t = Math.max(0, Math.min(1, (price - min) / (max - min))); + const colors = [ + [22, 163, 74], [101, 163, 13], [202, 138, 4], [234, 88, 12], [220, 38, 38], + ]; + const idx = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2); + const frac = t * (colors.length - 1) - idx; + const c0 = colors[idx], c1 = colors[idx + 1]; + return `rgb(${Math.round(c0[0] + (c1[0] - c0[0]) * frac)},${Math.round(c0[1] + (c1[1] - c0[1]) * frac)},${Math.round(c0[2] + (c1[2] - c0[2]) * frac)})`; + } + + function circleSvg(fill, label) { + return ` + + ${label} + `; + } + + // ── Brand colours ────────────────────────────────────────── + const BRAND_COLORS = { + 'ultramar': '#0057a8', + 'petro-canada': '#e4002b', + 'esso': '#003087', + 'shell': '#dd1d21', + 'pioneer': '#f47920', + 'couche-tard': '#c8102e', + 'circle k': '#c8102e', + 'crevier': '#e3000f', + 'irving': '#006747', + 'gilbert': '#00796b', + 'sonic': '#7b2d8b', + 'dépanneur': '#475569', + 'indépendant': '#334155', + }; + + const FALLBACK_PALETTE = [ + '#b45309', '#0369a1', '#065f46', '#7c3aed', + '#be185d', '#0f766e', '#b91c1c', '#1d4ed8', + '#15803d', '#a21caf', '#c2410c', '#0e7490', + ]; + + function brandColor(brand) { + const key = (brand || '').toLowerCase().trim(); + if (BRAND_COLORS[key]) return BRAND_COLORS[key]; + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff; + return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length]; + } + + function luminance(hex) { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + } + + function contrastFg(bgHex) { + const L = luminance(bgHex); + const onWhite = (L + 0.05) / 0.05; + const onBlack = 1.05 / (L + 0.05); + return onBlack > onWhite ? '#ffffff' : '#000000'; + } + + function brandBadgeHtml(brand) { + if (!brand) return ''; + const bg = brandColor(brand); + const fg = contrastFg(bg); + return `${brand}`; + } + + function priceDeltaHtml(pct, elapsedHours) { + if (pct == null) return ''; + if (Math.abs(pct) < 0.05) return ''; + const up = pct > 0; + const color = up ? '#dc2626' : '#16a34a'; + const arrow = up + ? '' + : ''; + const sign = up ? '+' : ''; + let timeLabel = ''; + if (elapsedHours != null) { + if (elapsedHours < 1) timeLabel = ' (< 1h)'; + else timeLabel = ` (${Math.min(24, Math.round(elapsedHours))}h)`; + } + return ` ${arrow}${sign}${pct.toFixed(2)}%${timeLabel}`; + } + + // ── Marker building ──────────────────────────────────────── + function rebuildMarkers(fitMap) { + clusterGroup.clearLayers(); + visibleSet.clear(); + + const scopedStations = currentRegion + ? allStations.filter(s => s.region === currentRegion) + : allStations; + const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0); + minPrice = prices.length ? Math.min(...prices) : 0; + maxPrice = prices.length ? Math.max(...prices) : 300; + + const slider = document.getElementById('price-slider'); + slider.min = Math.floor(minPrice); + slider.max = Math.ceil(maxPrice); + slider.value = Math.ceil(maxPrice); + document.getElementById('slider-min').textContent = Math.floor(minPrice) + '¢'; + document.getElementById('slider-max').textContent = Math.ceil(maxPrice) + '¢'; + document.getElementById('slider-label').textContent = Math.ceil(maxPrice) + '¢/L'; + + const fuelLabel = { regular: 'Régulier', super: 'Super', diesel: 'Diesel' }[currentFuel]; + document.getElementById('legend-title').textContent = `Prix ${fuelLabel.toLowerCase()} (¢/L)`; + document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢'; + document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢'; + + const GREY = '#9ca3af'; + allMarkers = allStations.map(s => { + const price = s[currentFuel]; + const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY; + const label = price > 0 ? price.toFixed(1) : '—'; + const icon = L.divIcon({ + html: circleSvg(fill, label), className: '', + iconSize: [36, 36], iconAnchor: [18, 18], popupAnchor: [0, -20], + }); + const marker = L.marker([s.lat, s.lng], { icon }); + marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel }; + const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.address + ', Québec')}`; + const d = stationDeltas[s.address] || {}; + const eh = d.elapsedHours != null ? d.elapsedHours : null; + let popup = `${s.name}
${brandBadgeHtml(s.brand)}
${s.address}
`; + popup += `
Régulier: ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`; + if (s.super > 0) popup += `
Super: ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`; + if (s.diesel > 0) popup += `
Diesel: ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`; + marker.bindPopup(popup); + return marker; + }); + + applyPriceFilter(parseFloat(slider.value), fitMap); + } + + function applyPriceFilter(priceMax, fitMap) { + const toAdd = []; + const toRemove = []; + + allStations.forEach((s, i) => { + const price = s[currentFuel]; + const inRegion = !currentRegion || s.region === currentRegion; + const visible = inRegion && (price <= 0 || price <= priceMax); + if (visible && !visibleSet.has(i)) { + toAdd.push(allMarkers[i]); + visibleSet.add(i); + } else if (!visible && visibleSet.has(i)) { + toRemove.push(allMarkers[i]); + visibleSet.delete(i); + } + }); + + if (toRemove.length) clusterGroup.removeLayers(toRemove); + if (toAdd.length) clusterGroup.addLayers(toAdd); + + document.getElementById('visible-count').textContent = + visibleSet.size + ' / ' + allStations.length + ' stations'; + + if (fitMap && currentRegion) { + const latlngs = allStations + .filter(s => s.region === currentRegion) + .map(s => [s.lat, s.lng]); + if (latlngs.length > 0) { + map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40], maxZoom: 13 }); + } + } + } + + // ── Wire up controls ─────────────────────────────────────── + function bindControls() { + document.getElementById('region-select').addEventListener('change', function () { + currentRegion = this.value; + rebuildMarkers(true); + }); + + document.getElementById('price-slider').addEventListener('input', function () { + const val = parseFloat(this.value); + document.getElementById('slider-label').textContent = val.toFixed(1) + '¢/L'; + clearTimeout(sliderTimer); + sliderTimer = setTimeout(() => applyPriceFilter(val), 80); + }); + + document.querySelectorAll('.cluster-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (btn.dataset.mode === clusterMode) return; + clusterMode = btn.dataset.mode; + document.querySelectorAll('.cluster-btn').forEach(b => + b.classList.toggle('active', b.dataset.mode === clusterMode)); + if (allStations.length) clusterGroup.refreshClusters(); + }); + }); + + document.querySelectorAll('.fuel-btn[data-fuel]').forEach(btn => { + btn.addEventListener('click', () => { + if (btn.dataset.fuel === currentFuel) return; + currentFuel = btn.dataset.fuel; + document.querySelectorAll('.fuel-btn[data-fuel]').forEach(b => + b.classList.toggle('active', b.dataset.fuel === currentFuel)); + rebuildMarkers(); + }); + }); + } + + // ── Initialise ───────────────────────────────────────────── + const loadingEl = document.getElementById('loading'); + try { + // Create map and cluster group now that the DOM is ready. + map = L.map('map', { zoomControl: false }).setView([46.8, -71.2], 7); + L.control.zoom({ position: 'topright' }).addTo(map); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap | Données: Régie de l\'énergie du Québec', + maxZoom: 18, + }).addTo(map); + + clusterGroup = L.markerClusterGroup({ + maxClusterRadius: 50, + spiderfyOnMaxZoom: true, + showCoverageOnHover: false, + zoomToBoundsOnClick: true, + disableClusteringAtZoom: 14, + iconCreateFunction: function (cluster) { + const children = cluster.getAllChildMarkers(); + const prices = children.map(m => m.fuelPrices[currentFuel]).filter(p => p > 0); + const count = cluster.getChildCount(); + let fill, label; + if (prices.length > 0) { + let displayPrice; + if (clusterMode === 'min') displayPrice = Math.min(...prices); + else if (clusterMode === 'max') displayPrice = Math.max(...prices); + else displayPrice = prices.reduce((a, b) => a + b, 0) / prices.length; + fill = priceColor(displayPrice, minPrice, maxPrice); + label = displayPrice.toFixed(1); + } else { + fill = '#9ca3af'; + label = count; + } + const size = count < 10 ? 44 : count < 50 ? 52 : 60; + const r = size / 2; + const fs = count < 10 ? 11 : count < 50 ? 10 : 9; + const svg = ` + + ${label} + `; + return L.divIcon({ html: svg, className: '', iconSize: [size, size], iconAnchor: [r, r] }); + }, + }); + + loadingEl.style.display = 'none'; + document.getElementById('map-stats').textContent = allStations.length + ' stations'; + + rebuildMarkers(); + map.addLayer(clusterGroup); + + bindControls(); + + // Expose for use after htmx page-swap back to the map page. + window.__mapInvalidate = function () { + setTimeout(() => map.invalidateSize(), 50); + }; + } catch (err) { + loadingEl.innerHTML = 'Erreur JavaScript: ' + err.message + ''; + } +})(); diff --git a/static/stats.js b/static/stats.js new file mode 100644 index 0000000..9ab6578 --- /dev/null +++ b/static/stats.js @@ -0,0 +1,117 @@ +// stats.js — Chart.js logic for Essence QC stats page. +// Reads window.__statsData injected by the server into the stats-content fragment. + +(function () { + 'use strict'; + + let statsChart = null; + + function darkMode() { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + function cssVar(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + } + + function initChart() { + const snaps = window.__statsData; + const canvas = document.getElementById('stats-chart-canvas'); + if (!snaps || !snaps.length || !canvas) return; + + // Destroy previous instance if the fragment was re-swapped. + if (statsChart) { + statsChart.destroy(); + statsChart = null; + } + + const textColor = cssVar('--foreground') || (darkMode() ? '#e4e4e7' : '#18181b'); + const gridColor = cssVar('--border') || (darkMode() ? '#3f3f46' : '#e4e4e7'); + const tooltipBg = cssVar('--card') || (darkMode() ? '#18181b' : '#ffffff'); + const tooltipText = cssVar('--card-foreground') || textColor; + + const labels = snaps.map(s => { + const d = new Date(s.generatedAt); + return d.toLocaleString('fr-CA', { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + }); + + const pt = snaps.length > 60 ? 0 : 3; + const datasets = [ + { + label: 'Régulier', + data: snaps.map(s => s.regularAvg > 0 ? +s.regularAvg.toFixed(2) : null), + borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', + tension: 0.3, fill: false, pointRadius: pt, + }, + { + label: 'Super', + data: snaps.map(s => s.superAvg > 0 ? +s.superAvg.toFixed(2) : null), + borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)', + tension: 0.3, fill: false, pointRadius: pt, + }, + { + label: 'Diesel', + data: snaps.map(s => s.dieselAvg > 0 ? +s.dieselAvg.toFixed(2) : null), + borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.1)', + tension: 0.3, fill: false, pointRadius: pt, + }, + ]; + + const chartOpts = { + responsive: true, + maintainAspectRatio: true, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + position: 'top', + labels: { color: textColor }, + }, + tooltip: { + backgroundColor: tooltipBg, + titleColor: tooltipText, + bodyColor: tooltipText, + borderColor: gridColor, + borderWidth: 1, + callbacks: { + label: ctx => ctx.dataset.label + ': ' + + (ctx.parsed.y != null ? ctx.parsed.y.toFixed(1) + '¢/L' : '—'), + }, + }, + }, + scales: { + x: { + ticks: { maxTicksLimit: 10, maxRotation: 30, color: textColor }, + grid: { color: gridColor }, + }, + y: { + title: { display: true, text: '¢/L', color: textColor }, + ticks: { callback: v => v + '¢', color: textColor }, + grid: { color: gridColor }, + }, + }, + }; + + statsChart = new Chart(canvas, { type: 'line', data: { labels, datasets }, options: chartOpts }); + } + + // Called inline from stats-content.html after __statsData is set. + window.__initStatsChart = initChart; + + // Re-render on dark-mode change. + if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (statsChart) initChart(); + }); + } + + // Re-init after any htmx swap of #stats-content. + document.body.addEventListener('htmx:afterSwap', function (evt) { + if (evt.detail && evt.detail.target && evt.detail.target.id === 'stats-content') { + // __statsData was updated inline by the swapped fragment; just init the chart. + if (window.__statsData) initChart(); + } + }); +})(); diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..afefe1b --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,316 @@ + + + + + + Essence Québec + + + + + + + + + + + + +
+ +
+ Essence QC + +
+ + +
+ {{if eq .Page "map"}}{{template "map-content" .}}{{end}} + {{if eq .Page "stats"}}{{template "stats-content-shell" .}}{{end}} +
+
+ + + + diff --git a/templates/map.html b/templates/map.html new file mode 100644 index 0000000..9d460b6 --- /dev/null +++ b/templates/map.html @@ -0,0 +1,68 @@ +{{define "map-content"}} + +
+
+ Chargement des données... +
+ + +
+
+ +
+
+ + + +
+ +
+
+ + + +
+
+ ? +
+ Prix affiché sur chaque groupe de stations :

+ Min — prix le plus bas du groupe
+ Moy — prix moyen du groupe
+ Max — prix le plus élevé du groupe

+ La couleur reflète également la valeur affichée. +
+
+
+ + +
+ - + - +
+
+
+ +
{{.LastUpdated}}
+ +
+

Prix régulier (¢/L)

+
+
+ - + - +
+
{{.StationCount}} stations
+
+
+ + + +{{end}} diff --git a/templates/stats-content.html b/templates/stats-content.html new file mode 100644 index 0000000..257b726 --- /dev/null +++ b/templates/stats-content.html @@ -0,0 +1,106 @@ + +
+
+

Régulier — Moyenne

+

{{fmtPrice .Last.RegularAvg}}

+

¢/L

+
+
+

Régulier — Min

+

{{fmtPrice .Last.RegularMin}}

+

¢/L

+
+
+

Régulier — Max

+

{{fmtPrice .Last.RegularMax}}

+

¢/L

+
+
+

Super — Moyenne

+

{{fmtPrice .Last.SuperAvg}}

+

¢/L

+
+
+

Diesel — Moyenne

+

{{fmtPrice .Last.DieselAvg}}

+

¢/L

+
+
+

Stations

+

{{if .Last.StationCount}}{{.Last.StationCount}}{{else}}—{{end}}

+

dernière mise à jour

+
+
+ + +
+

Évolution des prix

+

Prix moyen par carburant (¢/L)

+
+ + + +
+ {{if .Snapshots}} + + + {{else}} +

Pas encore assez de données historiques.

+ {{end}} +
+ + +
+

Historique récent

+

10 dernières mises à jour

+
+ + + + + + + + + + + + + + {{if .HistoryRows}} + {{range .HistoryRows}} + + + + + + + + + + {{end}} + {{else}} + + {{end}} + +
DateRégulier moy.Régulier minRégulier maxSuper moy.Diesel moy.Stations
{{.Date}}{{.RegularAvg}}{{.RegularMin}}{{.RegularMax}}{{.SuperAvg}}{{.DieselAvg}}{{.StationCount}}
Aucune donnée
+
+
diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..ae3a1a1 --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,34 @@ +{{define "stats-content-shell"}} + +
+

Statistiques des prix

+ + +
+ + + +
+ + +
+
Chargement...
+
+
+ + +{{end}}