feat replace gauge color zones with red-yellow-green scale

Replace the binary good/bad color zones on the front page reading gauges with a continuous red → yellow → green gradient. - Add readingQuality() scoring: 0 (worst) to 1 (best), per-probe - DO uses higherIsBetter mode (higher values are increasingly green) - Symmetric probes (pH, EC, ORP) taper from green in the good range to yellow then red at extremes - Marker dot color interpolates dynamically to match its position - CSS gradient is now set per-probe by JS via --range-gradient - Remove hardcoded text-success on target range label

Commit
9afcb3797d2673ca7189e2fea4805868c6abf9fd
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/public/css/app.css
index 38ced7e4..5ec0aa3a 100644..100644
@@ -175,10 +175,7 @@
175 175 Reading range bar (home page quick-reading cards)
176 176 ---------------------------------------------------------------- */
177 177 .reading-quick-item {
178 Removed: --warn-low: 27%;
179 Removed: --good-start: 35%;
180 Removed: --good-end: 65%;
181 Removed: --warn-high: 73%;
178 Added: --range-gradient: linear-gradient(90deg, #b42318 0%, #e8a308 35%, #276749 50%, #e8a308 65%, #b42318 100%);
182 179 }
183 180
184 181 /* Three-column label row under the range bar */
@@ -190,16 +187,7 @@
190 187 position: relative;
191 188 height: 0.85rem;
192 189 border-radius: 999px;
193 Removed: background:
194 Removed: linear-gradient(
195 Removed: 90deg,
196 Removed: var(--fapg-danger) 0%,
197 Removed: var(--fapg-warn) var(--warn-low),
198 Removed: var(--fapg-accent) var(--good-start),
199 Removed: var(--fapg-accent) var(--good-end),
200 Removed: var(--fapg-warn) var(--warn-high),
201 Removed: var(--fapg-danger) 100%
202 Removed: );
190 Added: background: var(--range-gradient);
203 191 }
204 192
205 193 .reading-range-marker {
@@ -214,10 +202,6 @@
214 202 box-shadow: 0 1px 4px rgba(23, 33, 27, 0.35);
215 203 transform: translate(-50%, -50%);
216 204 }
217 Removed:
218 Removed: .reading-quick-item.is-good .reading-range-marker { background: var(--fapg-accent); }
219 Removed: .reading-quick-item.is-low .reading-range-marker,
220 Removed: .reading-quick-item.is-high .reading-range-marker { background: var(--fapg-danger); }
221 205
222 206 /* Status node detail-list term labels */
223 207 .status-node-dt {
roles/dashboard/public/js/dashboard/constants.js
index c7a16b08..e448f4df 100644..100644
@@ -11,7 +11,7 @@
11 11 export const MIN_Y_SPAN = { ph: 2, do: 5, orp: 200, ec: 500 };
12 12 export const READING_RANGES = {
13 13 ph: { min: 5.5, goodMin: 6.4, goodMax: 7.2, max: 8.5, precision: 2 },
14 Removed: do: { min: 0, goodMin: 5.5, goodMax: 10, max: 14, precision: 2 },
14 Added: do: { min: 0, goodMin: 5.5, goodMax: 10, max: 14, precision: 2, higherIsBetter: true },
15 15 orp: { min: 100, goodMin: 250, goodMax: 400, max: 500, precision: 0 },
16 16 ec: { min: 0, goodMin: 300, goodMax: 1500, max: 2500, precision: 0 }
17 17 };
roles/dashboard/public/js/dashboard/quick-readings.js
index a77a0052..ab2db8a0 100644..100644
@@ -10,51 +10,97 @@
10 10 return Math.max(0, Math.min(100, ((value - range.min) / (range.max - range.min)) * 100));
11 11 }
12 12
13 Removed: function readingState(value, range) {
14 Removed: if (!Number.isFinite(value)) return "unknown";
15 Removed: if (value < range.goodMin) return "low";
16 Removed: if (value > range.goodMax) return "high";
17 Removed: return "good";
13 Added: function readingQuality(value, range) {
14 Added: if (!Number.isFinite(value)) return 0;
15 Added:
16 Added: if (range.higherIsBetter) {
17 Added: // 0 at min, 1 at goodMax and above
18 Added: if (value >= range.goodMax) return 1;
19 Added: if (value <= range.min) return 0;
20 Added: return (value - range.min) / (range.goodMax - range.min);
21 Added: }
22 Added:
23 Added: // Symmetric: 1 inside good range, tapering to 0 at extremes
24 Added: if (value >= range.goodMin && value <= range.goodMax) return 1;
25 Added: if (value < range.goodMin) {
26 Added: return Math.max(0, (value - range.min) / (range.goodMin - range.min));
27 Added: }
28 Added: return Math.max(0, (range.max - value) / (range.max - range.goodMax));
18 29 }
19 30
20 Removed: function readingStateLabel(state) {
21 Removed: return { good: "Good", low: "Too low", high: "Too high", unknown: "No data" }[state] || "No data";
31 Added: function qualityLabel(quality) {
32 Added: if (quality >= 0.8) return "Good";
33 Added: if (quality >= 0.5) return "Fair";
34 Added: if (quality > 0) return "Poor";
35 Added: return "No data";
22 36 }
23 37
38 Added: function qualityState(quality) {
39 Added: if (quality >= 0.8) return "good";
40 Added: if (quality >= 0.5) return "fair";
41 Added: if (quality > 0) return "poor";
42 Added: return "unknown";
43 Added: }
44 Added:
24 45 function configureRange(item, range, unit) {
25 46 const goodStart = rangePercent(range.goodMin, range);
26 47 const goodEnd = rangePercent(range.goodMax, range);
27 48
28 Removed: item.style.setProperty("--warn-low", `${Math.max(0, goodStart - 8)}%`);
29 Removed: item.style.setProperty("--good-start", `${goodStart}%`);
30 Removed: item.style.setProperty("--good-end", `${goodEnd}%`);
31 Removed: item.style.setProperty("--warn-high", `${Math.min(100, goodEnd + 8)}%`);
49 Added: if (range.higherIsBetter) {
50 Added: item.style.setProperty("--range-gradient",
51 Added: `linear-gradient(90deg, #b42318 0%, #e8a308 ${goodStart}%, #276749 ${goodEnd}%, #276749 100%)`);
52 Added: } else {
53 Added: item.style.setProperty("--range-gradient",
54 Added: `linear-gradient(90deg, #b42318 0%, #e8a308 ${Math.max(0, goodStart - 5)}%, #276749 ${goodStart}%, #276749 ${goodEnd}%, #e8a308 ${Math.min(100, goodEnd + 5)}%, #b42318 100%)`);
55 Added: }
56 Added:
32 57 item.querySelector("[data-reading-quick-min]").textContent = `${formatValue(range.min, range)} ${unit}`;
33 58 item.querySelector("[data-reading-quick-target]").textContent =
34 59 `${formatValue(range.goodMin, range)}-${formatValue(range.goodMax, range)} ${unit}`;
35 60 item.querySelector("[data-reading-quick-max]").textContent = `${formatValue(range.max, range)} ${unit}`;
36 61 }
37 62
63 Added: function markerColor(quality) {
64 Added: // Interpolate red → yellow → green based on quality (0–1)
65 Added: if (quality <= 0.5) {
66 Added: const t = quality / 0.5;
67 Added: const r = Math.round(180 + (232 - 180) * t);
68 Added: const g = Math.round(35 + (163 - 35) * t);
69 Added: const b = Math.round(24 + (8 - 24) * t);
70 Added: return `rgb(${r}, ${g}, ${b})`;
71 Added: }
72 Added: const t = (quality - 0.5) / 0.5;
73 Added: const r = Math.round(232 + (39 - 232) * t);
74 Added: const g = Math.round(163 + (103 - 163) * t);
75 Added: const b = Math.round(8 + (73 - 8) * t);
76 Added: return `rgb(${r}, ${g}, ${b})`;
77 Added: }
78 Added:
38 79 function updateItem(item, reading) {
39 80 const probe = item.dataset.readingQuickProbe;
40 81 const range = READING_RANGES[probe];
41 82 const unit = item.dataset.readingQuickUnit || reading?.unit || "";
42 83 const label = item.dataset.readingQuickLabel || probe;
43 84 const value = Number(reading?.value);
44 Removed: const state = readingState(value, range);
85 Added: const quality = readingQuality(value, range);
86 Added: const state = qualityState(quality);
45 87 const percent = Number.isFinite(value) ? rangePercent(value, range) : 50;
46 88 const timestamp = reading?.received_at || reading?.timestamp;
47 89 const age = timestamp ? formatAge(ageMs(timestamp)) : "unknown age";
48 90
49 91 configureRange(item, range, unit);
50 Removed: item.classList.remove("is-good", "is-low", "is-high", "is-unknown");
92 Added: item.classList.remove("is-good", "is-fair", "is-poor", "is-unknown");
51 93 item.classList.add(`is-${state}`);
52 Removed: item.querySelector("[data-reading-quick-marker]").style.left = `${percent}%`;
94 Added:
95 Added: const marker = item.querySelector("[data-reading-quick-marker]");
96 Added: marker.style.left = `${percent}%`;
97 Added: marker.style.backgroundColor = Number.isFinite(value) ? markerColor(quality) : "";
98 Added:
53 99 const meter = item.querySelector(".reading-range");
54 100 meter.setAttribute("aria-valuenow", Math.round(percent).toString());
55 Removed: meter.setAttribute("aria-label", `${label} ${readingStateLabel(state)} at ${formatValue(value, range)} ${unit}`);
101 Added: meter.setAttribute("aria-label", `${label} ${qualityLabel(quality)} at ${formatValue(value, range)} ${unit}`);
56 102 item.querySelector("[data-reading-quick-value]").textContent = Number.isFinite(value)
57 Removed: ? `${formatValue(value, range)} ${unit} - ${readingStateLabel(state)} - ${age}`
103 Added: ? `${formatValue(value, range)} ${unit} - ${qualityLabel(quality)} - ${age}`
58 104 : "No reading yet";
59 105 }
60 106
roles/dashboard/templates/dashboard/index.html.ep
index a3634887..05b4001b 100644..100644
@@ -38,7 +38,7 @@
38 38 </div>
39 39 <div class="reading-range-labels d-grid gap-1 small text-muted">
40 40 <span data-reading-quick-min></span>
41 Removed: <span class="text-success fw-bold text-center" data-reading-quick-target></span>
41 Added: <span class="fw-bold text-center" data-reading-quick-target></span>
42 42 <span class="text-end" data-reading-quick-max></span>
43 43 </div>
44 44 </div>