refactor rationalise chart card headers

Create a shared chart-card-header.html.ep partial used by insights, weather, and probe cards. All graph cards now have: - A dropdown menu (download data, share placeholder) - An optional ⓘ info toggle for explainer text - Consistent layout across all pages Probe cards keep their custom header (title + variation) but share the same button group pattern.

Commit
0b3bfe255ed568c907138060ce0340ca6495a30d
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/t/01-graph-page.t
index 606f8822..fa6ff63c 100644..100644
@@ -24,7 +24,7 @@
24 24 ->element_exists(
25 25 'div.toolbar-items div.graph-control .timeframe-control[role="group"]'
26 26 )
27 Removed: ->text_is( 'a[data-raw-download][data-probe="ph"]', 'Download raw data' )
27 Added: ->text_is( 'a[data-raw-download][data-probe="ph"]', 'Download data' )
28 28 ->element_exists(
29 29 'button[data-timeframe="day"].timeframe-button.btn-outline-secondary'
30 30 )
roles/dashboard/templates/dashboard/chart-card-header.html.ep
index 00000000..8f3d12e5 000000..100644
@@ -0,0 +1,56 @@
1 Added: <%
2 Added: # Usage: include 'dashboard/chart-card-header',
3 Added: # title => 'Chart Title',
4 Added: # info_id => 'info-unique-id', # optional
5 Added: # info_text => 'Explainer text...', # optional (requires info_id)
6 Added: # download_href => '/path/to/csv', # optional
7 Added: my $chart_title = stash('title') // 'Chart';
8 Added: my $info_id = stash('info_id');
9 Added: my $info_text = stash('info_text');
10 Added: my $download_href = stash('download_href');
11 Added: %>
12 Added: <div class="card-header d-flex justify-content-between align-items-start gap-2">
13 Added: <h2 class="h6 mb-0"><%== $chart_title %></h2>
14 Added: <div class="d-flex align-items-center gap-1 flex-shrink-0">
15 Added: % if ($info_id && $info_text) {
16 Added: <button class="btn btn-sm btn-link text-body-secondary p-0 info-toggle"
17 Added: type="button"
18 Added: data-bs-toggle="collapse"
19 Added: data-bs-target="#<%= $info_id %>"
20 Added: aria-expanded="false"
21 Added: aria-controls="<%= $info_id %>"
22 Added: aria-label="About this chart">
23 Added: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
24 Added: <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
25 Added: <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
26 Added: </svg>
27 Added: </button>
28 Added: % }
29 Added: <div class="dropdown">
30 Added: <button
31 Added: class="btn btn-sm btn-outline-secondary dropdown-toggle"
32 Added: type="button"
33 Added: data-bs-toggle="dropdown"
34 Added: aria-expanded="false"
35 Added: aria-label="Chart actions">
36 Added: </button>
37 Added: <ul class="dropdown-menu dropdown-menu-end">
38 Added: % if ($download_href) {
39 Added: <li>
40 Added: <a class="dropdown-item" href="<%= $download_href %>">Download data</a>
41 Added: </li>
42 Added: % }
43 Added: <li>
44 Added: <button class="dropdown-item" type="button" disabled>Share (coming soon)</button>
45 Added: </li>
46 Added: </ul>
47 Added: </div>
48 Added: </div>
49 Added: </div>
50 Added: % if ($info_id && $info_text) {
51 Added: <div class="collapse" id="<%= $info_id %>">
52 Added: <div class="card-body border-bottom py-2">
53 Added: <p class="text-body-secondary small mb-0"><%== $info_text %></p>
54 Added: </div>
55 Added: </div>
56 Added: % }
roles/dashboard/templates/dashboard/insights.html.ep
index bf821c2f..9c24068d 100644..100644
@@ -14,31 +14,7 @@
14 14 <div class="row g-3">
15 15 <div class="col-12" id="stability">
16 16 <div class="card">
17 Removed: <div class="card-header d-flex align-items-start justify-content-between">
18 Removed: <h2 class="h6 mb-0">System Stability</h2>
19 Removed: <button class="btn btn-sm btn-link text-body-secondary p-0 ms-2 info-toggle"
20 Removed: type="button"
21 Removed: data-bs-toggle="collapse"
22 Removed: data-bs-target="#info-stability"
23 Removed: aria-expanded="false"
24 Removed: aria-controls="info-stability"
25 Removed: aria-label="About this chart">
26 Removed: <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
27 Removed: <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
28 Removed: <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
29 Removed: </svg>
30 Removed: </button>
31 Removed: </div>
32 Removed: <div class="collapse" id="info-stability">
33 Removed: <div class="card-body border-bottom py-2">
34 Removed: <p class="text-body-secondary small mb-0">
35 Removed: A <strong>lower score means more stable</strong>.
36 Removed: This index is the rolling standard deviation (σ) of all probe values
37 Removed: over a 6-hour window, normalised to each probe's optimal range.
38 Removed: Spikes indicate rapid or large deviations from normal operating conditions.
39 Removed: </p>
40 Removed: </div>
41 Removed: </div>
17 Added: %= include 'dashboard/chart-card-header', title => 'System Stability', info_id => 'info-stability', info_text => 'A <strong>lower score means more stable</strong>. Rolling standard deviation (σ) of all probe values over a 6-hour window, normalised to each optimal range. Spikes indicate rapid or large deviations from normal conditions.'
42 18 <div class="card-body">
43 19 <div class="chart-wrap">
44 20 <canvas id="chart-stability" role="img" aria-label="System stability score — lower is better"></canvas>
@@ -49,29 +25,7 @@
49 25
50 26 <div class="col-12" id="rate-of-change">
51 27 <div class="card">
52 Removed: <div class="card-header d-flex align-items-start justify-content-between">
53 Removed: <h2 class="h6 mb-0">Rate of Change</h2>
54 Removed: <button class="btn btn-sm btn-link text-body-secondary p-0 ms-2 info-toggle"
55 Removed: type="button"
56 Removed: data-bs-toggle="collapse"
57 Removed: data-bs-target="#info-derivatives"
58 Removed: aria-expanded="false"
59 Removed: aria-controls="info-derivatives"
60 Removed: aria-label="About this chart">
61 Removed: <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
62 Removed: <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
63 Removed: <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
64 Removed: </svg>
65 Removed: </button>
66 Removed: </div>
67 Removed: <div class="collapse" id="info-derivatives">
68 Removed: <div class="card-body border-bottom py-2">
69 Removed: <p class="text-body-secondary small mb-0">
70 Removed: Normalised rate of change per probe (% of optimal range per hour).
71 Removed: Values near zero mean the parameter is steady.
72 Removed: </p>
73 Removed: </div>
74 Removed: </div>
28 Added: %= include 'dashboard/chart-card-header', title => 'Rate of Change', info_id => 'info-derivatives', info_text => 'Normalised rate of change per probe (% of optimal range per hour). Values near zero mean the parameter is steady.'
75 29 <div class="card-body">
76 30 <div class="chart-wrap">
77 31 <canvas id="chart-derivatives" role="img" aria-label="Normalised rate of change per probe"></canvas>
@@ -82,28 +36,7 @@
82 36
83 37 <div class="col-12" id="diurnal">
84 38 <div class="card">
85 Removed: <div class="card-header d-flex align-items-start justify-content-between">
86 Removed: <h2 class="h6 mb-0">DO Diurnal Pattern</h2>
87 Removed: <button class="btn btn-sm btn-link text-body-secondary p-0 ms-2 info-toggle"
88 Removed: type="button"
89 Removed: data-bs-toggle="collapse"
90 Removed: data-bs-target="#info-diurnal"
91 Removed: aria-expanded="false"
92 Removed: aria-controls="info-diurnal"
93 Removed: aria-label="About this chart">
94 Removed: <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
95 Removed: <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
96 Removed: <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
97 Removed: </svg>
98 Removed: </button>
99 Removed: </div>
100 Removed: <div class="collapse" id="info-diurnal">
101 Removed: <div class="card-body border-bottom py-2">
102 Removed: <p class="text-body-secondary small mb-0">
103 Removed: Day/night DO cycle &mdash; each line is one day, bold is the average.
104 Removed: </p>
105 Removed: </div>
106 Removed: </div>
39 Added: %= include 'dashboard/chart-card-header', title => 'DO Diurnal Pattern', info_id => 'info-diurnal', info_text => 'Day/night DO cycle &mdash; each line is one day, bold is the 7-day average. Shows photosynthesis-driven oxygen production during daylight hours.'
107 40 <div class="card-body">
108 41 <div class="chart-wrap">
109 42 <canvas id="chart-diurnal" role="img" aria-label="Dissolved oxygen diurnal overlay"></canvas>
@@ -114,28 +47,7 @@
114 47
115 48 <div class="col-12" id="do-orp-overlay">
116 49 <div class="card">
117 Removed: <div class="card-header d-flex align-items-start justify-content-between">
118 Removed: <h2 class="h6 mb-0">DO &amp; ORP Over Time</h2>
119 Removed: <button class="btn btn-sm btn-link text-body-secondary p-0 ms-2 info-toggle"
120 Removed: type="button"
121 Removed: data-bs-toggle="collapse"
122 Removed: data-bs-target="#info-do-orp"
123 Removed: aria-expanded="false"
124 Removed: aria-controls="info-do-orp"
125 Removed: aria-label="About this chart">
126 Removed: <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
127 Removed: <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
128 Removed: <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
129 Removed: </svg>
130 Removed: </button>
131 Removed: </div>
132 Removed: <div class="collapse" id="info-do-orp">
133 Removed: <div class="card-body border-bottom py-2">
134 Removed: <p class="text-body-secondary small mb-0">
135 Removed: Dual-axis overlay &mdash; correlated drops indicate oxygen-driven redox shifts.
136 Removed: </p>
137 Removed: </div>
138 Removed: </div>
50 Added: %= include 'dashboard/chart-card-header', title => 'DO &amp; ORP Over Time', info_id => 'info-do-orp', info_text => 'Dual-axis overlay with outdoor temperature. Correlated DO/ORP drops indicate oxygen-driven redox shifts. The grey dashed line shows outdoor temperature for correlation.'
139 51 <div class="card-body">
140 52 <div class="chart-wrap">
141 53 <canvas id="chart-overlay-do-orp" role="img" aria-label="DO and ORP overlay chart"></canvas>
@@ -146,28 +58,7 @@
146 58
147 59 <div class="col-12" id="ph-do-lag">
148 60 <div class="card">
149 Removed: <div class="card-header d-flex align-items-start justify-content-between">
150 Removed: <h2 class="h6 mb-0">pH &#8596; DO Relationship</h2>
151 Removed: <button class="btn btn-sm btn-link text-body-secondary p-0 ms-2 info-toggle"
152 Removed: type="button"
153 Removed: data-bs-toggle="collapse"
154 Removed: data-bs-target="#info-ph-do"
155 Removed: aria-expanded="false"
156 Removed: aria-controls="info-ph-do"
157 Removed: aria-label="About this chart">
158 Removed: <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
159 Removed: <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
160 Removed: <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
161 Removed: </svg>
162 Removed: </button>
163 Removed: </div>
164 Removed: <div class="collapse" id="info-ph-do">
165 Removed: <div class="card-body border-bottom py-2">
166 Removed: <p class="text-body-secondary small mb-0">
167 Removed: Nitrification link &mdash; DO drops often precede pH drops.
168 Removed: </p>
169 Removed: </div>
170 Removed: </div>
61 Added: %= include 'dashboard/chart-card-header', title => 'pH &#8596; DO Relationship', info_id => 'info-ph-do', info_text => 'Nitrification link &mdash; DO drops often precede pH drops. When bacteria consume oxygen for nitrification, they produce acid which lowers pH.'
171 62 <div class="card-body">
172 63 <div class="chart-wrap">
173 64 <canvas id="chart-ph-do-lag" role="img" aria-label="pH and DO relationship overlay"></canvas>
roles/dashboard/templates/dashboard/probe-card.html.ep
index 357460a4..5e89adc9 100644..100644
@@ -5,6 +5,7 @@
5 5 % ec => 'Electrical conductivity reflects dissolved nutrient concentration. 300–1500 µS/cm is typical. Rising EC means nutrient accumulation; drops after water changes.',
6 6 % );
7 7 % my $info_id = "info-probe-$probe->{key}";
8 Added: % my $dl_href = "/downloads/readings.csv?probe=$probe->{key}&timeframe=day&range=1";
8 9 <div class="card">
9 10 <div class="card-header d-flex justify-content-between align-items-start gap-2">
10 11 <div class="probe-card-title min-w-0">
@@ -13,7 +14,7 @@
13 14 </h1>
14 15 <p class="small mb-0 mt-1" data-variation="<%= $probe->{key} %>"></p>
15 16 </div>
16 Removed: <div class="d-flex align-items-center gap-2 flex-shrink-0">
17 Added: <div class="d-flex align-items-center gap-1 flex-shrink-0">
17 18 <button class="btn btn-sm btn-link text-body-secondary p-0 info-toggle"
18 19 type="button"
19 20 data-bs-toggle="collapse"
@@ -21,7 +22,7 @@
21 22 aria-expanded="false"
22 23 aria-controls="<%= $info_id %>"
23 24 aria-label="About this probe">
24 Removed: <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
25 Added: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
25 26 <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
26 27 <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
27 28 </svg>
@@ -38,9 +39,9 @@
38 39 <li>
39 40 <a
40 41 class="dropdown-item graph-download-button"
41 Removed: href="/downloads/readings.csv?probe=<%= $probe->{key} %>&amp;timeframe=day&amp;range=1"
42 Added: href="<%= $dl_href %>"
42 43 data-raw-download
43 Removed: data-probe="<%= $probe->{key} %>">Download raw data</a>
44 Added: data-probe="<%= $probe->{key} %>">Download data</a>
44 45 </li>
45 46 </ul>
46 47 </div>
roles/dashboard/templates/dashboard/weather.html.ep
index 7dfb5fb6..0c3ec69c 100644..100644
@@ -14,9 +14,7 @@
14 14 <div class="row g-3">
15 15 <div class="col-12" id="weather-temperature">
16 16 <div class="card">
17 Removed: <div class="card-header">
18 Removed: <h2 class="h6 mb-0">Temperature</h2>
19 Removed: </div>
17 Added: %= include 'dashboard/chart-card-header', title => 'Temperature', info_id => 'info-weather-temp', info_text => 'Outdoor air temperature at 2m above ground from the Météo France AROME model (1.5 km resolution). Relevant for estimating greenhouse and water temperature trends.'
20 18 <div class="card-body">
21 19 <div class="chart-wrap">
22 20 <canvas id="chart-weather-temperature" role="img" aria-label="Outdoor temperature over time"></canvas>
@@ -27,9 +25,7 @@
27 25
28 26 <div class="col-12" id="weather-humidity">
29 27 <div class="card">
30 Removed: <div class="card-header">
31 Removed: <h2 class="h6 mb-0">Humidity</h2>
32 Removed: </div>
28 Added: %= include 'dashboard/chart-card-header', title => 'Humidity', info_id => 'info-weather-humid', info_text => 'Outdoor relative humidity at 2m. High humidity reduces evaporation and plant transpiration; low humidity increases water loss from open systems.'
33 29 <div class="card-body">
34 30 <div class="chart-wrap">
35 31 <canvas id="chart-weather-humidity" role="img" aria-label="Outdoor relative humidity over time"></canvas>
@@ -40,9 +36,7 @@
40 36
41 37 <div class="col-12" id="weather-rain">
42 38 <div class="card">
43 Removed: <div class="card-header">
44 Removed: <h2 class="h6 mb-0">Rainfall</h2>
45 Removed: </div>
39 Added: %= include 'dashboard/chart-card-header', title => 'Rainfall', info_id => 'info-weather-rain', info_text => 'Hourly rainfall in mm. Heavy rain can dilute outdoor systems, cause overflow, and affect nutrient balance. Indoor systems are less affected but ambient humidity rises.'
46 40 <div class="card-body">
47 41 <div class="chart-wrap">
48 42 <canvas id="chart-weather-rain" role="img" aria-label="Rainfall over time"></canvas>