new stack

This commit is contained in:
Polen 2026-04-07 21:42:30 -04:00
parent 6922e9d63b
commit ac9737b125
10 changed files with 1476 additions and 1221 deletions

316
templates/layout.html Normal file
View file

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Essence Québec</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<link rel="stylesheet" href="https://unpkg.com/@knadh/oat/oat.min.css">
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/@knadh/oat/oat.min.js"></script>
<style>
/* ── Theme: Gouvernement du Québec blue ─────────────── */
:root {
--primary: rgb(9 87 151); /* #095797 */
--primary-foreground: rgb(255 255 255);
--ring: rgb(9 87 151);
}
@media (prefers-color-scheme: dark) {
:root {
--primary: rgb(41 121 193);
--primary-foreground: rgb(255 255 255);
--ring: rgb(41 121 193);
}
}
/* ── Layout ─────────────────────────────────────────── */
html, body { height: 100%; margin: 0; padding: 0; }
/* ── Top nav ─────────────────────────────────────────── */
#topnav {
display: flex; align-items: center; gap: 0;
height: 44px; padding: 0 16px;
background: var(--card);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 2000;
}
#topnav .brand {
font-weight: 700; font-size: 15px; letter-spacing: -0.2px;
margin-right: 24px; color: var(--foreground);
text-decoration: none;
}
#topnav nav { display: flex; gap: 2px; }
#topnav nav a {
display: flex; align-items: center; gap: 6px;
padding: 5px 12px; border-radius: 6px;
text-decoration: none; color: var(--muted-foreground);
font-size: 13px; font-weight: 500;
transition: background 0.12s, color 0.12s;
cursor: pointer;
}
#topnav nav a:hover {
background: var(--muted);
color: var(--foreground);
}
#topnav nav a[aria-current="page"] {
background: var(--primary);
color: var(--primary-foreground);
}
#topnav nav a svg { flex-shrink: 0; }
/* ── App shell ───────────────────────────────────────── */
#app {
display: flex; flex-direction: column;
height: 100%;
}
#content {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
}
/* ── Map page ────────────────────────────────────────── */
#page-map { display: flex; flex-direction: column; flex: 1; position: relative; }
#map { flex: 1; min-height: 0; }
/* Slider + fuel panel */
#slider-panel {
position: absolute; top: 12px; left: 12px;
padding: 10px 14px;
z-index: 1000; font-size: 13px; min-width: 280px;
}
#slider-panel .fuel-btns { display: flex; gap: 6px; margin-bottom: 10px; }
#region-select {
width: 100%; margin-bottom: 10px;
padding: 5px 8px; font-size: 12px;
border: 1px solid var(--border); border-radius: 4px;
background: var(--secondary); color: var(--secondary-foreground);
cursor: pointer;
}
#slider-panel label { display: block; margin-bottom: 6px; font-weight: 600; }
#price-slider { width: 100%; cursor: pointer; }
#slider-value {
display: flex; justify-content: space-between;
margin-top: 4px; font-size: 11px;
color: var(--muted-foreground);
}
#visible-count { margin-top: 6px; font-size: 11px; color: var(--muted-foreground); }
/* Map overlay panels — sit above the Leaflet tiles */
.map-panel {
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
/* Legend */
#legend {
position: absolute; bottom: 20px; right: 20px;
padding: 12px 16px;
z-index: 1000; font-size: 13px; min-width: 180px;
}
#legend h3 { margin-bottom: 8px; font-size: 14px; }
.legend-gradient {
height: 16px; border-radius: 4px;
background: linear-gradient(to right, #16a34a, #65a30d, #ca8a04, #ea580c, #dc2626);
margin-bottom: 4px;
}
.legend-labels {
display: flex; justify-content: space-between;
font-size: 11px; color: var(--muted-foreground);
}
#map-stats { margin-top: 8px; font-size: 11px; color: var(--muted-foreground); }
#last-updated {
position: absolute; bottom: 8px; left: 12px;
padding: 5px 10px;
z-index: 1000; font-size: 11px;
color: var(--muted-foreground);
}
.pin-icon { filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35)); }
.cluster-info-tip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px; height: 16px;
border-radius: 50%;
border: 1px solid var(--muted-foreground);
color: var(--muted-foreground);
font-size: 10px; font-weight: 700;
cursor: default;
flex-shrink: 0;
}
.cluster-info-tip:hover .cluster-info-tooltip { display: block; }
.cluster-info-tooltip {
display: none;
position: absolute;
left: 22px; top: 50%;
transform: translateY(-50%);
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
padding: 10px 12px;
font-size: 12px; font-weight: 400;
white-space: nowrap;
z-index: 2000;
pointer-events: none;
line-height: 1.6;
}
.fuel-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-foreground);
cursor: pointer; font-size: 12px;
transition: all 0.15s;
}
.fuel-btn:hover { background: var(--muted); }
.fuel-btn.active {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
/* ── Loading overlay ─────────────────────────────────── */
#loading {
position: fixed; inset: 0;
background: var(--background);
opacity: 0.92;
display: flex; align-items: center; justify-content: center;
z-index: 10000; font-size: 1.1em;
color: var(--foreground);
gap: 12px;
}
/* ── Stats page ──────────────────────────────────────── */
#page-stats { display: flex; padding: 24px; overflow-y: auto; flex: 1; flex-direction: column; }
#page-stats h2 { margin-bottom: 20px; }
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 28px;
}
.stat-cards .card { margin: 0; }
.stat-cards .card header { padding-bottom: 4px; }
.stat-cards .card header p { font-size: 12px; color: var(--muted-foreground); margin: 0; }
.stat-value { font-size: 28px; font-weight: 700; margin: 4px 0 0; }
.stat-sub { font-size: 12px; color: var(--muted-foreground); margin-top: 2px; }
.chart-wrap {
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 28px;
}
.chart-wrap h3 { margin-bottom: 4px; }
.chart-wrap .chart-subtitle {
font-size: 12px; color: var(--muted-foreground); margin-bottom: 16px;
}
.range-btns { display: flex; gap: 6px; margin-bottom: 16px; }
.range-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-foreground);
cursor: pointer; font-size: 12px;
}
.range-btn:hover { background: var(--muted); }
.range-btn.active {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
#stats-chart-canvas { width: 100% !important; max-height: 320px; }
.no-data { color: var(--muted-foreground); font-size: 14px; padding: 24px 0; text-align: center; }
.table-wrap { overflow-x: auto; }
/* ── htmx loading indicator ──────────────────────────── */
.htmx-request #stats-content { opacity: 0.5; transition: opacity 0.2s; }
</style>
</head>
<body>
<div id="app">
<!-- ── Top nav ── -->
<header id="topnav">
<span class="brand">Essence QC</span>
<nav>
<a href="/map"
id="nav-map"
{{if eq .Page "map"}}aria-current="page"{{end}}
hx-get="/map"
hx-target="#content"
hx-push-url="/map"
hx-swap="innerHTML"
hx-on::after-request="window.__onPageSwap && window.__onPageSwap('map')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/>
<line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/>
</svg>
Carte
</a>
<a href="/stats"
id="nav-stats"
{{if eq .Page "stats"}}aria-current="page"{{end}}
hx-get="/stats"
hx-target="#content"
hx-push-url="/stats"
hx-swap="innerHTML"
hx-on::after-request="window.__onPageSwap && window.__onPageSwap('stats')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
Statistiques
</a>
</nav>
</header>
<!-- ── Content area (htmx swap target) ── -->
<div id="content">
{{if eq .Page "map"}}{{template "map-content" .}}{{end}}
{{if eq .Page "stats"}}{{template "stats-content-shell" .}}{{end}}
</div>
</div>
<script>
// Update nav active state after htmx page swaps.
window.__onPageSwap = function(page) {
document.querySelectorAll('#topnav nav a').forEach(a => a.removeAttribute('aria-current'));
const target = document.getElementById('nav-' + page);
if (target) target.setAttribute('aria-current', 'page');
};
// On browser back/forward, trigger the right content reload.
window.addEventListener('popstate', function() {
const path = window.location.pathname;
const page = path === '/stats' ? 'stats' : 'map';
htmx.ajax('GET', '/' + page, { target: '#content', swap: 'innerHTML' });
window.__onPageSwap(page);
});
</script>
</body>
</html>

68
templates/map.html Normal file
View file

@ -0,0 +1,68 @@
{{define "map-content"}}
<!-- Loading overlay -->
<div id="loading">
<div aria-busy="true"></div>
<span>Chargement des données...</span>
</div>
<!-- ── MAP PAGE ── -->
<div id="page-map">
<div id="map"></div>
<div id="slider-panel" class="map-panel">
<div class="fuel-btns">
<button class="fuel-btn active" data-fuel="regular">Régulier</button>
<button class="fuel-btn" data-fuel="super">Super</button>
<button class="fuel-btn" data-fuel="diesel">Diesel</button>
</div>
<select id="region-select">
<option value="">Toutes les régions</option>
{{range .Regions}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:10px;">
<div class="fuel-btns" id="cluster-btns" style="margin-bottom:0;">
<button class="fuel-btn cluster-btn" data-mode="min">Min</button>
<button class="fuel-btn cluster-btn active" data-mode="avg">Moy</button>
<button class="fuel-btn cluster-btn" data-mode="max">Max</button>
</div>
<div class="cluster-info-tip">
?
<div class="cluster-info-tooltip">
Prix affiché sur chaque groupe de stations :<br><br>
<strong>Min</strong> — prix le plus bas du groupe<br>
<strong>Moy</strong> — prix moyen du groupe<br>
<strong>Max</strong> — prix le plus élevé du groupe<br><br>
La couleur reflète également la valeur affichée.
</div>
</div>
</div>
<label for="price-slider">Prix max: <span id="slider-label">-</span></label>
<input type="range" id="price-slider" min="0" max="300" step="0.5" value="300">
<div id="slider-value">
<span id="slider-min">-</span>
<span id="slider-max">-</span>
</div>
<div id="visible-count"></div>
</div>
<div id="last-updated" class="map-panel">{{.LastUpdated}}</div>
<div id="legend" class="map-panel">
<h3 id="legend-title">Prix régulier (¢/L)</h3>
<div class="legend-gradient"></div>
<div class="legend-labels">
<span id="min-price">-</span>
<span id="max-price">-</span>
</div>
<div id="map-stats">{{.StationCount}} stations</div>
</div>
</div>
<script>
window.__stations = {{.StationsJSON}};
window.__deltas = {{.DeltasJSON}};
</script>
<script src="/map.js"></script>
{{end}}

View file

@ -0,0 +1,106 @@
<!-- Stat cards -->
<div class="stat-cards">
<article class="card">
<header><p>Régulier — Moyenne</p></header>
<p class="stat-value">{{fmtPrice .Last.RegularAvg}}</p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Régulier — Min</p></header>
<p class="stat-value">{{fmtPrice .Last.RegularMin}}</p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Régulier — Max</p></header>
<p class="stat-value">{{fmtPrice .Last.RegularMax}}</p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Super — Moyenne</p></header>
<p class="stat-value">{{fmtPrice .Last.SuperAvg}}</p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Diesel — Moyenne</p></header>
<p class="stat-value">{{fmtPrice .Last.DieselAvg}}</p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Stations</p></header>
<p class="stat-value">{{if .Last.StationCount}}{{.Last.StationCount}}{{else}}—{{end}}</p>
<p class="stat-sub">dernière mise à jour</p>
</article>
</div>
<!-- Chart -->
<div class="chart-wrap">
<h3>Évolution des prix</h3>
<p class="chart-subtitle">Prix moyen par carburant (¢/L)</p>
<div class="range-btns">
<button class="range-btn {{if eq .Days 7}}active{{end}}"
hx-get="/stats/content"
hx-target="#stats-content"
hx-include="#stats-region-select"
hx-vals='{"days": "7"}'
onclick="document.getElementById('stats-days').value='7'">7 jours</button>
<button class="range-btn {{if eq .Days 30}}active{{end}}"
hx-get="/stats/content"
hx-target="#stats-content"
hx-include="#stats-region-select"
hx-vals='{"days": "30"}'
onclick="document.getElementById('stats-days').value='30'">30 jours</button>
<button class="range-btn {{if eq .Days 0}}active{{end}}"
hx-get="/stats/content"
hx-target="#stats-content"
hx-include="#stats-region-select"
hx-vals='{"days": "0"}'
onclick="document.getElementById('stats-days').value='0'">Tout</button>
</div>
{{if .Snapshots}}
<canvas id="stats-chart-canvas"></canvas>
<script>
window.__statsData = {{.SnapshotsJSON}};
if (window.__initStatsChart) window.__initStatsChart();
</script>
{{else}}
<p class="no-data">Pas encore assez de données historiques.</p>
{{end}}
</div>
<!-- History table -->
<div class="chart-wrap">
<h3>Historique récent</h3>
<p class="chart-subtitle">10 dernières mises à jour</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Régulier moy.</th>
<th>Régulier min</th>
<th>Régulier max</th>
<th>Super moy.</th>
<th>Diesel moy.</th>
<th>Stations</th>
</tr>
</thead>
<tbody>
{{if .HistoryRows}}
{{range .HistoryRows}}
<tr>
<td>{{.Date}}</td>
<td>{{.RegularAvg}}</td>
<td>{{.RegularMin}}</td>
<td>{{.RegularMax}}</td>
<td>{{.SuperAvg}}</td>
<td>{{.DieselAvg}}</td>
<td>{{.StationCount}}</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="7" style="text-align:center;color:var(--muted-foreground);">Aucune donnée</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>

34
templates/stats.html Normal file
View file

@ -0,0 +1,34 @@
{{define "stats-content-shell"}}
<!-- ── STATS PAGE ── -->
<div id="page-stats">
<h2>Statistiques des prix</h2>
<!-- Region filter -->
<div style="margin-bottom: 20px;">
<select id="stats-region-select"
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); color: var(--secondary-foreground); cursor: pointer; min-width: 220px;"
hx-get="/stats/content"
hx-target="#stats-content"
hx-include="#stats-days"
hx-trigger="change"
name="region">
<option value="">Toutes les régions</option>
{{range .Regions}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
<!-- Hidden input tracks current days selection for region changes -->
<input type="hidden" id="stats-days" name="days" value="7">
</div>
<!-- Dynamic content: cards + chart + table -->
<div id="stats-content"
hx-get="/stats/content?days=7"
hx-trigger="load"
hx-swap="innerHTML">
<div style="text-align:center; color:var(--muted-foreground); padding:40px 0;">Chargement...</div>
</div>
</div>
<script src="/stats.js"></script>
{{end}}