[Perl] DAQ system for the FAPG.
feat refine responsive monitoring experience
Add desktop inline and mobile bottom navigation with active-state semantics. Restructure chart controls into responsive sticky desktop panels with shared timeframe dropdowns. Add the French randomized aquaponic koan card and mobile FAPG call affordance. Extend dashboard coverage for navigation, controls, and home content.
Changed files
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Pages.pm
- roles/dashboard/public/css/app.css
- roles/dashboard/t/00-homepage.t
- roles/dashboard/t/01-graph-page.t
- roles/dashboard/t/05-status-page.t
- roles/dashboard/t/06-downloads.t
- roles/dashboard/t/08-insights.t
- roles/dashboard/t/09-weather.t
- roles/dashboard/templates/dashboard/graph-control.html.ep
- roles/dashboard/templates/dashboard/graph.html.ep
- roles/dashboard/templates/dashboard/index.html.ep
- roles/dashboard/templates/dashboard/insights.html.ep
- roles/dashboard/templates/dashboard/probes.html.ep
- roles/dashboard/templates/dashboard/weather.html.ep
- roles/dashboard/templates/layouts/default.html.ep
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Pages.pm
@@ -1,13 +1,93 @@
1
1
package FAPG::DAQ::Dashboard::Controller::Pages;
2
2
use Mojo::Base 'Mojolicious::Controller', -signatures;
3
3
4
Added:
use utf8;
4
5
use POSIX qw(strftime);
5
6
7
Added:
my @HOME_KOANS = (
8
Added:
q{La truite demanda au basilic : qui filtre qui ?},
9
Added:
q{Une tomate se pencha vers le bassin et apprit à écouter.},
10
Added:
q{Quand le pH-mètre dort, les racines comptent les bulles.},
11
Added:
q{La carotte ne trouva aucune rivière, mais ses feuilles étaient vertes.},
12
Added:
q{Une pompe silencieuse enseigne la patience à la serre.},
13
Added:
q{Le filtre rêve de pluie et se réveille propre.},
14
Added:
q{Un bassin calme peut apprendre à tout un jardin à respirer.},
15
Added:
q{La truite traversa la question et ne laissa aucune réponse sèche.},
16
Added:
q{Une feuille tomba dans le puisard et devint demain.},
17
Added:
q{Le concombre salua le biofiltre ; tous deux poussèrent plus droit.},
18
Added:
q{Ne poursuis pas l’eau claire ; nourris bien les microbes.},
19
Added:
q{Le basilic murmura : plus d’oxygène, moins de hâte.},
20
Added:
q{Une truite sage ne discute jamais météo avec un radis.},
21
Added:
q{Le caillou du bac de culture connaît toutes les saisons.},
22
Added:
q{Quand le siphon cloche soupire, la laitue se souvient de la pluie.},
23
Added:
q{Les racines demandèrent l’obscurité ; le soleil attendit poliment.},
24
Added:
q{Une goutte d’eau contient un étang, sauf si elle contient une tomate.},
25
Added:
q{Le poisson fabriqua du compost et l’appela méditation.},
26
Added:
q{Le persil regarda les bulles jusqu’à devenir joyeux.},
27
Added:
q{Un tuyau propre n’a rien à prouver.},
28
Added:
q{La truite vit son reflet et nourrit les bactéries.},
29
Added:
q{Une betterave têtue sait quand même apprécier une bonne circulation.},
30
Added:
q{La pompe cessa de râler lorsque la menthe se mit à chanter.},
31
Added:
q{Une feuille rouge n’est pas une alarme ; parfois c’est de la poésie.},
32
Added:
q{Le bassin était plein, mais le basilic fit de la place pour l’émerveillement.},
33
Added:
q{Un lombricomposteur rit de mille tableurs.},
34
Added:
q{La laitue mesure le temps en nouvelles racines.},
35
Added:
q{Le biofilm ne se presse jamais, et arrive pourtant partout.},
36
Added:
q{La truite ne lit pas le graphique, mais connaît les matins froids.},
37
Added:
q{La tomate demanda un soutien ; le treillis répondit : naturellement.},
38
Added:
q{L’eau claire et les pensées troubles peuvent partager la même surface.},
39
Added:
q{Le bac de culture pardonna le débordement avant le petit-déjeuner.},
40
Added:
q{Une carotte dans la perlite rêve d’anciens lits de rivière.},
41
Added:
q{Le nitrate monta, puis s’assit tranquillement avec le chou kale.},
42
Added:
q{Une nageoire peut remuer bien plus que l’eau.},
43
Added:
q{Le sage dit : vérifie la pompe. Puis vérifie ton thé.},
44
Added:
q{Même le modeste bulleur fait un petit tonnerre.},
45
Added:
q{Le radis poussa rond parce que personne ne lui demanda d’être carré.},
46
Added:
q{Une truite et un navet se rencontrèrent en silence ; le système fonctionna.},
47
Added:
q{Le pH dériva doucement, comme un nuage avec un presse-papiers.},
48
Added:
q{Aucune racine n’est seule sous une eau bien aérée.},
49
Added:
q{Le chou kale remercia les poissons sans faire de discours.},
50
Added:
q{Le siphon cloche ne sonne que pour ceux qui cessent de le regarder.},
51
Added:
q{Une bonne récolte commence par un carnet d’entretien ennuyeux.},
52
Added:
q{Le ver dit : tout finit par devenir de la terre.},
53
Added:
q{La fenêtre de la serre s’ouvrit ; l’humidité sourit.},
54
Added:
q{Une petite bulle peut porter un très grand mardi.},
55
Added:
q{L’eau devint limpide quand la fermière cessa de lui faire la morale.},
56
Added:
q{La truite envisagea l’éveil, puis demanda le petit-déjeuner.},
57
Added:
q{La dernière feuille de laitue salua la première goutte de pluie.},
58
Added:
);
59
Added:
6
60
sub index ($self) {
61
Added:
my @home_koan_masters = (
62
Added:
q{Lao Chou},
63
Added:
q{Truite-Zu},
64
Added:
q{Maître Poireau},
65
Added:
q{Sœur Carotte},
66
Added:
q{Gillbert le Sage},
67
Added:
q{Zen d’Endive},
68
Added:
q{Maître Haricot},
69
Added:
q{Sage Saumon},
70
Added:
q{Dame Betterave},
71
Added:
q{Cornelius la Carpe},
72
Added:
q{Rōshi Radis},
73
Added:
q{Frère Fenouil},
74
Added:
q{Capitaine Chou-Fleur},
75
Added:
q{Mademoiselle Mâche},
76
Added:
q{Sœur Sardine},
77
Added:
q{Maître Courgette},
78
Added:
q{Vénérable Aubergine},
79
Added:
q{Moine Maquereau},
80
Added:
q{Léon Laitue},
81
Added:
q{Professeur Petit Pois},
82
Added:
);
83
Added:
7
84
$self->render(
8
Removed:
template => 'dashboard/index',
9
Removed:
probes => $self->probes,
10
Removed:
page_title => 'Dashboard',
85
Added:
template => 'dashboard/index',
86
Added:
probes => $self->probes,
87
Added:
home_koan => $HOME_KOANS[ int rand @HOME_KOANS ],
88
Added:
home_koan_master => $home_koan_masters[ int rand @home_koan_masters ],
89
Added:
nav_page => 'home',
90
Added:
page_title => 'Dashboard',
11
91
);
12
92
}
13
93
roles/dashboard/public/css/app.css
@@ -19,6 +19,7 @@
19
19
--fapg-border: #e2e8f0;
20
20
--fapg-text: #1e293b;
21
21
--fapg-text-muted: #64748b;
22
Added:
--fapg-nav-active: #166534;
22
23
--navbar-height: 3.5rem;
23
24
}
24
25
@@ -34,6 +35,250 @@
34
35
overflow-x: clip;
35
36
}
36
37
38
Added:
39
Added:
/* ----------------------------------------------------------------
40
Added:
Home koan card — a small dose of aquaponic Zen
41
Added:
---------------------------------------------------------------- */
42
Added:
.home-koan-card {
43
Added:
inline-size: 100%;
44
Added:
margin-inline: auto;
45
Added:
border-color: var(--fapg-border);
46
Added:
background: var(--fapg-surface);
47
Added:
}
48
Added:
49
Added:
@media (min-width: 768px) {
50
Added:
.home-koan-card {
51
Added:
inline-size: clamp(22rem, 38.2%, 32rem);
52
Added:
}
53
Added:
}
54
Added:
55
Added:
.home-koan-card .card-body {
56
Added:
padding: 0.75rem 1rem;
57
Added:
}
58
Added:
59
Added:
.home-koan {
60
Added:
margin: 0;
61
Added:
color: var(--fapg-text-muted);
62
Added:
font-size: 0.9rem;
63
Added:
line-height: 1.35;
64
Added:
}
65
Added:
66
Added:
.home-koan-text {
67
Added:
font-style: italic;
68
Added:
}
69
Added:
70
Added:
.home-koan-attribution {
71
Added:
font-size: 0.94em;
72
Added:
white-space: nowrap;
73
Added:
}
74
Added:
75
Added:
/* ----------------------------------------------------------------
76
Added:
Mobile home masthead
77
Added:
---------------------------------------------------------------- */
78
Added:
@media (max-width: 767.98px) {
79
Added:
.mobile-home-masthead {
80
Added:
margin-bottom: 0.75rem;
81
Added:
padding: 0.5rem 0;
82
Added:
}
83
Added:
84
Added:
.mobile-home-masthead-row {
85
Added:
display: flex;
86
Added:
align-items: center;
87
Added:
justify-content: space-between;
88
Added:
min-height: 2.75rem;
89
Added:
}
90
Added:
91
Added:
.mobile-home-brand {
92
Added:
color: var(--fapg-nav-active);
93
Added:
font-size: 1.25rem;
94
Added:
font-weight: 800;
95
Added:
letter-spacing: -0.02em;
96
Added:
}
97
Added:
98
Added:
.mobile-home-phone {
99
Added:
display: inline-grid;
100
Added:
place-items: center;
101
Added:
width: 2.75rem;
102
Added:
height: 2.75rem;
103
Added:
border: 1px solid var(--fapg-border);
104
Added:
border-radius: 50%;
105
Added:
color: var(--fapg-nav-active);
106
Added:
text-decoration: none;
107
Added:
}
108
Added:
109
Added:
.mobile-home-phone svg {
110
Added:
width: 1.25rem;
111
Added:
height: 1.25rem;
112
Added:
fill: none;
113
Added:
stroke: currentcolor;
114
Added:
stroke-linecap: round;
115
Added:
stroke-linejoin: round;
116
Added:
stroke-width: 2;
117
Added:
}
118
Added:
119
Added:
.mobile-home-phone:focus-visible {
120
Added:
outline: 3px solid #14532d;
121
Added:
outline-offset: 3px;
122
Added:
}
123
Added:
}
124
Added:
@media (min-width: 768px) {
125
Added:
#navbar .navbar-nav .nav-link.active {
126
Added:
color: var(--fapg-nav-active);
127
Added:
}
128
Added:
}
129
Added:
130
Added:
/* ----------------------------------------------------------------
131
Added:
Chart controls — compact mobile strip and desktop side panel
132
Added:
---------------------------------------------------------------- */
133
Added:
.dashboard-page-layout,
134
Added:
.dashboard-page-content {
135
Added:
min-width: 0;
136
Added:
}
137
Added:
138
Added:
.dashboard-controls-panel {
139
Added:
position: sticky;
140
Added:
top: 0;
141
Added:
z-index: 1015;
142
Added:
display: flex;
143
Added:
justify-content: center;
144
Added:
margin-bottom: 1rem;
145
Added:
padding: 0.5rem 0;
146
Added:
background: var(--bs-body-bg, #fff);
147
Added:
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
148
Added:
}
149
Added:
150
Added:
.controls-panel-title {
151
Added:
position: absolute;
152
Added:
width: 1px;
153
Added:
height: 1px;
154
Added:
padding: 0;
155
Added:
overflow: hidden;
156
Added:
clip: rect(0, 0, 0, 0);
157
Added:
white-space: nowrap;
158
Added:
border: 0;
159
Added:
}
160
Added:
161
Added:
.graph-control {
162
Added:
display: flex;
163
Added:
align-items: center;
164
Added:
gap: 0.5rem;
165
Added:
flex-wrap: nowrap;
166
Added:
}
167
Added:
168
Added:
.graph-control-group {
169
Added:
display: flex;
170
Added:
align-items: center;
171
Added:
}
172
Added:
173
Added:
.graph-control-range-group {
174
Added:
flex: 0 0 10rem;
175
Added:
inline-size: 10rem;
176
Added:
}
177
Added:
178
Added:
.graph-control-range-group .range-control {
179
Added:
inline-size: 100%;
180
Added:
}
181
Added:
182
Added:
.graph-control-range-group .range-label {
183
Added:
flex: 1 1 auto;
184
Added:
justify-content: center;
185
Added:
}
186
Added:
187
Added:
.control-group-label {
188
Added:
display: none;
189
Added:
}
190
Added:
191
Added:
.range-control {
192
Added:
flex-wrap: nowrap;
193
Added:
}
194
Added:
195
Added:
.range-control .range-label {
196
Added:
white-space: nowrap;
197
Added:
}
198
Added:
199
Added:
.dashboard-page-content,
200
Added:
.dashboard-page-content [id] {
201
Added:
scroll-margin-block-start: 6rem;
202
Added:
}
203
Added:
204
Added:
@media (min-width: 768px) {
205
Added:
.dashboard-page-layout {
206
Added:
display: grid;
207
Added:
grid-template-columns: minmax(12rem, 14rem) minmax(0, 1fr);
208
Added:
gap: 1.5rem;
209
Added:
align-items: start;
210
Added:
}
211
Added:
212
Added:
.dashboard-controls-panel {
213
Added:
top: calc(var(--navbar-height) + 1rem);
214
Added:
align-self: start;
215
Added:
block-size: calc(100dvh - var(--navbar-height) - 2rem);
216
Added:
display: block;
217
Added:
margin-bottom: 0;
218
Added:
padding: 1rem;
219
Added:
overflow-y: auto;
220
Added:
overscroll-behavior: contain;
221
Added:
border: 1px solid var(--fapg-border);
222
Added:
border-radius: var(--bs-border-radius);
223
Added:
background: var(--fapg-surface);
224
Added:
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
225
Added:
}
226
Added:
227
Added:
.controls-panel-title {
228
Added:
position: static;
229
Added:
width: auto;
230
Added:
height: auto;
231
Added:
margin: 0 0 1rem;
232
Added:
overflow: visible;
233
Added:
clip: auto;
234
Added:
color: var(--fapg-text);
235
Added:
font-size: 0.9rem;
236
Added:
font-weight: 700;
237
Added:
line-height: 1.25;
238
Added:
white-space: normal;
239
Added:
}
240
Added:
241
Added:
.dashboard-controls-panel .graph-control {
242
Added:
align-items: stretch;
243
Added:
justify-content: flex-start;
244
Added:
gap: 0.75rem;
245
Added:
block-size: fit-content;
246
Added:
flex-direction: column;
247
Added:
}
248
Added:
249
Added:
.dashboard-controls-panel .graph-control-group {
250
Added:
display: block;
251
Added:
}
252
Added:
253
Added:
.dashboard-controls-panel .graph-control-range-group {
254
Added:
flex: 0 0 auto;
255
Added:
inline-size: 100%;
256
Added:
}
257
Added:
258
Added:
.dashboard-controls-panel .control-group-label {
259
Added:
display: block;
260
Added:
margin-bottom: 0.35rem;
261
Added:
color: var(--fapg-text-muted);
262
Added:
font-size: 0.75rem;
263
Added:
font-weight: 700;
264
Added:
}
265
Added:
266
Added:
.dashboard-controls-panel .range-control,
267
Added:
.dashboard-controls-panel .timeframe-control,
268
Added:
.dashboard-controls-panel .timeframe-dropdown-toggle {
269
Added:
width: 100%;
270
Added:
}
271
Added:
272
Added:
.dashboard-controls-panel .timeframe-control .dropdown-menu {
273
Added:
width: 100%;
274
Added:
}
275
Added:
276
Added:
.dashboard-page-content,
277
Added:
.dashboard-page-content [id] {
278
Added:
scroll-margin-block-start: calc(var(--navbar-height) + 1rem);
279
Added:
}
280
Added:
}
281
Added:
37
282
/* Toolbar graph controls — scroll horizontally without causing page overflow */
38
283
.graph-control-scroll {
39
284
max-width: 100%;
@@ -49,15 +294,6 @@
49
294
padding-bottom: 0.5rem;
50
295
}
51
296
52
Removed:
.sticky-controls {
53
Removed:
position: sticky;
54
Removed:
top: var(--navbar-height);
55
Removed:
z-index: 1015;
56
Removed:
background: var(--bs-body-bg, #fff);
57
Removed:
padding-top: 0.5rem;
58
Removed:
padding-bottom: 0.5rem;
59
Removed:
}
60
Removed:
61
297
/* Info toggle button in insight card headers */
62
298
.info-toggle {
63
299
line-height: 1;
@@ -412,4 +648,49 @@
412
648
413
649
.insight-theme {
414
650
scroll-margin-top: 5rem;
651
Added:
}
652
Added:
653
Added:
/* ----------------------------------------------------------------
654
Added:
Mobile primary navigation
655
Added:
---------------------------------------------------------------- */
656
Added:
@media (max-width: 767.98px) {
657
Added:
body {
658
Added:
padding-bottom: calc(4.5rem + env(safe-area-inset-bottom));
659
Added:
}
660
Added:
}
661
Added:
662
Added:
.mobile-bottom-nav {
663
Added:
position: fixed;
664
Added:
z-index: 1030;
665
Added:
right: 0;
666
Added:
bottom: 0;
667
Added:
left: 0;
668
Added:
display: flex;
669
Added:
min-height: calc(4.5rem + env(safe-area-inset-bottom));
670
Added:
padding-bottom: env(safe-area-inset-bottom);
671
Added:
border-top: 1px solid var(--fapg-border);
672
Added:
background: var(--bs-body-bg, #fff);
673
Added:
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
674
Added:
}
675
Added:
676
Added:
.mobile-bottom-nav-link {
677
Added:
display: grid;
678
Added:
flex: 1 1 0;
679
Added:
place-items: center;
680
Added:
min-height: 4.5rem;
681
Added:
padding: 0.5rem;
682
Added:
color: var(--fapg-text-muted);
683
Added:
font-size: 1rem;
684
Added:
font-weight: 600;
685
Added:
text-align: center;
686
Added:
text-decoration: none;
687
Added:
}
688
Added:
689
Added:
.mobile-bottom-nav-link.active {
690
Added:
color: var(--fapg-nav-active);
691
Added:
}
692
Added:
693
Added:
.mobile-bottom-nav-link:focus-visible {
694
Added:
outline: 3px solid #14532d;
695
Added:
outline-offset: -3px;
415
696
}
roles/dashboard/t/00-homepage.t
@@ -11,20 +11,38 @@
11
11
12
12
$t->get_ok('/')
13
13
->status_is(200)
14
Removed:
->element_exists('body nav.navbar + main')
15
Removed:
->text_is( 'a.navbar-brand[href="/"]', 'FAPG' )
16
Removed:
->text_is( '.navbar-nav a.nav-link.dropdown-toggle', 'Probes' )
17
Removed:
->text_is( '.navbar-nav a.nav-link[href="/insights"]', 'Insights' )
18
Removed:
->text_is( '.navbar-nav a.nav-link[href="/downloads"]', 'Downloads' )
14
Added:
->element_exists('body > main')
15
Added:
->text_is( 'a.navbar-brand[href="/"]', 'FAPG' )
16
Added:
->text_is( '.navbar-nav a.nav-link.dropdown-toggle', 'Probes' )
17
Added:
->text_is( '.navbar-nav a.nav-link[href="/insights"]', 'Insights' )
18
Added:
->text_is( '.navbar-nav a.nav-link[href="/downloads"]', 'Downloads' )
19
19
->element_exists_not('.navbar-nav a.nav-link.active')
20
Added:
->element_exists(
21
Added:
'nav[data-mobile-navigation][aria-label="Primary navigation"]')
22
Added:
->text_is( '[data-mobile-navigation] a[href="/"]', 'Home' )
23
Added:
->element_exists(
24
Added:
'[data-mobile-navigation] a.active[href="/"][aria-current="page"]')
25
Added:
->text_is( '[data-mobile-navigation] a[href="/insights"]', 'Insights' )
26
Added:
->text_is( '[data-mobile-navigation] a[href="/probes"]', 'Probes' )
27
Added:
->text_is( '[data-mobile-navigation] a[href="/weather"]', 'Weather' )
28
Added:
->element_exists_not('[data-mobile-navigation] a[href="/downloads"]')
29
Added:
->element_exists_not('button.navbar-toggler')
20
30
->element_exists_not('nav[aria-label="Breadcrumb"]')
31
Added:
->element_exists('header[data-home-masthead]')
32
Added:
->text_is('.mobile-home-brand', 'FAPG')
33
Added:
->element_exists('figure[data-home-koan-card]')
34
Added:
->element_exists_not('figure[data-home-koan-card].d-md-none')
35
Added:
->text_like('[data-home-koan-card] .home-koan-text', qr/\S/)
36
Added:
->element_exists('[data-home-koan-card] blockquote em.home-koan-text')
37
Added:
->text_like('[data-home-koan-card] [data-home-koan-attribution]', qr/\A\x{2014}\s+\S/)
21
38
->element_exists(
22
Removed:
'section.reading-quick-status [data-reading-quick-list]')
39
Added:
'a[data-home-phone][href="tel:+33787044193"][aria-label="Call FAPG at 07 87 04 41 93"]'
40
Added:
)
41
Added:
->element_exists('section.reading-quick-status [data-reading-quick-list]')
23
42
->element_exists_not('[data-reading-quick-status]')
24
43
->element_exists('[data-weather-forecast]')
25
44
->element_exists('canvas#chart-weather-forecast')
26
Removed:
->element_exists(
27
Removed:
'footer a[href="https://www.lafermeaquaponique.com/"]');
45
Added:
->element_exists('footer a[href="https://www.lafermeaquaponique.com/"]');
28
46
29
47
for my $probe (qw(ph do orp ec)) {
30
48
$t->element_exists(
roles/dashboard/t/01-graph-page.t
@@ -11,18 +11,25 @@
11
11
12
12
$t->get_ok('/probes/ph')
13
13
->status_is(200)
14
Removed:
->element_exists('.navbar-text')
15
Removed:
->text_like('.navbar-text', qr/pH/)
16
Removed:
->element_exists('.sticky-controls .graph-control')
14
Added:
->element_exists_not('#navbar-title')
15
Added:
->element_exists(
16
Added:
'main section[data-controls-panel][aria-labelledby="probe-controls-title"]'
17
Added:
)
18
Added:
->text_is( '#probe-controls-title', 'pH chart controls' )
19
Added:
->element_exists('#probe-controls .graph-control')
20
Added:
->text_is( '#probe-controls [data-control-group="range"]', 'Range' )
21
Added:
->text_is( '#probe-controls [data-control-group="timeframe"]',
22
Added:
'Timeframe' )
17
23
->text_is( '.card-header h2 a[href="/probes/ph"]', 'pH' )
18
24
->element_exists('.navbar-nav a.nav-link.active.dropdown-toggle')
19
25
->element_exists('section#probe-ph[data-panel="ph"] canvas#chart-ph')
20
26
->text_is( 'a[data-raw-download][data-probe="ph"]', 'Download data' )
21
27
->element_exists(
22
Removed:
'button[data-timeframe="day"].timeframe-button.btn-outline-secondary'
23
Removed:
)
24
Removed:
->element_exists_not('button[data-timeframe="day"].timeframe-button.btn-success')
28
Added:
'button[data-timeframe="day"].timeframe-button.dropdown-item')
25
29
->element_exists(
30
Added:
'.timeframe-control.dropdown > button[data-timeframe-dropdown-label]')
31
Added:
->element_exists_not('.timeframe-control.btn-group')
32
Added:
->element_exists(
26
33
'script[src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"]'
27
34
. '[integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"]'
28
35
)
@@ -31,8 +38,7 @@
31
38
32
39
for my $timeframe (qw(hour day week month year)) {
33
40
$t->element_exists(
34
Removed:
qq{.timeframe-control button[data-timeframe="$timeframe"]}
35
Removed:
);
41
Added:
qq{.timeframe-control button[data-timeframe="$timeframe"]});
36
42
}
37
43
38
44
$t->get_ok('/probes/unknown')->status_is(404);
@@ -40,14 +46,19 @@
40
46
41
47
$t->get_ok('/probes')
42
48
->status_is(200)
43
Removed:
->text_like('.navbar-text', qr/Probe Readings/)
44
Removed:
->element_exists('.sticky-controls .graph-control')
49
Added:
->element_exists(
50
Added:
'main section[data-controls-panel][aria-labelledby="probes-controls-title"]'
51
Added:
)
52
Added:
->text_is( '#probes-controls-title', 'Probe chart controls' )
53
Added:
->element_exists(
54
Added:
'#probes-controls .graph-control[data-default-timeframe="day"]')
45
55
->element_exists('main.container')
46
56
->element_exists('div.probes-grid')
47
57
->element_exists('.navbar-nav a.nav-link.active.dropdown-toggle');
48
58
49
59
for my $probe (qw(ph do orp ec)) {
50
Removed:
$t->element_exists(qq{div.probes-grid .card-header h2 a[href="/probes/$probe"]});
60
Added:
$t->element_exists(
61
Added:
qq{div.probes-grid .card-header h2 a[href="/probes/$probe"]});
51
62
$t->element_exists(
52
63
qq{section#probe-${probe}[data-panel="$probe"] canvas#chart-$probe});
53
64
$t->element_exists(qq{a[data-raw-download][data-probe="$probe"]});
roles/dashboard/t/05-status-page.t
@@ -11,7 +11,6 @@
11
11
12
12
$t->get_ok('/probes/status')
13
13
->status_is(200)
14
Removed:
->text_like('.navbar-text', qr/Status/)
15
14
->element_exists('main.container')
16
15
->element_exists('.list-group');
17
16
roles/dashboard/t/06-downloads.t
@@ -11,21 +11,25 @@
11
11
12
12
$t->get_ok('/downloads')
13
13
->status_is(200)
14
Removed:
->text_like('.navbar-text', qr/Downloads/)
15
Removed:
->element_exists('.navbar-nav a.nav-link.active[href="/downloads"][aria-current="page"]')
14
Added:
->element_exists(
15
Added:
'.navbar-nav a.nav-link.active[href="/downloads"][aria-current="page"]')
16
16
->element_exists('form.downloads-form[action="/downloads/readings.csv"]')
17
17
->element_exists('[data-downloads-error]')
18
18
->element_exists('input[name="date_range"][value="week"][checked]')
19
19
->element_exists(
20
Removed:
'[data-custom-date-fields].collapse:not(.show) input[name="from"][type="date"]')
20
Added:
'[data-custom-date-fields].collapse:not(.show) input[name="from"][type="date"]'
21
Added:
)
21
22
->element_exists(
22
Removed:
'[data-custom-date-fields].collapse:not(.show) input[name="to"][type="date"]')
23
Added:
'[data-custom-date-fields].collapse:not(.show) input[name="to"][type="date"]'
24
Added:
)
23
25
->text_like( 'button.downloads-button[type="submit"]', qr/Download CSV/ )
24
26
->element_exists(
25
Removed:
'button.downloads-button[type="submit"][data-download-button] [data-download-spinner].d-none')
27
Added:
'button.downloads-button[type="submit"][data-download-button] [data-download-spinner].d-none'
28
Added:
)
26
29
->text_like(
27
30
'button.downloads-button[type="submit"][formaction="/downloads/readings.xlsx"]',
28
Removed:
qr/Download \.xlsx/ )
31
Added:
qr/Download \.xlsx/
32
Added:
)
29
33
->element_exists(
30
34
'button.downloads-button[type="submit"][formaction="/downloads/readings.xlsx"][data-download-button] [data-download-spinner].d-none'
31
35
);
roles/dashboard/t/08-insights.t
@@ -12,9 +12,19 @@
12
12
subtest 'insights page renders with all sections' => sub {
13
13
$t->get_ok('/insights')
14
14
->status_is(200)
15
Removed:
->text_is('title', 'FAPG DAQ Insights')
16
Removed:
->text_like('.navbar-text', qr/Insights/)
15
Added:
->text_is( 'title', 'FAPG DAQ Insights' )
17
16
->element_exists('.navbar-nav a.nav-link.active[href="/insights"]')
17
Added:
->element_exists_not('[data-home-masthead]')
18
Added:
->element_exists_not('[data-home-koan-card]')
19
Added:
->element_exists(
20
Added:
'[data-mobile-navigation] a.active[href="/insights"][aria-current="page"]'
21
Added:
)
22
Added:
->element_exists(
23
Added:
'main section[data-controls-panel][aria-labelledby="insights-controls-title"]'
24
Added:
)
25
Added:
->text_is( '#insights-controls-title', 'Insights controls' )
26
Added:
->element_exists(
27
Added:
'#insights-controls .graph-control[data-default-timeframe="day"]')
18
28
->element_exists('[data-default-timeframe="day"]');
19
29
};
20
30
@@ -32,7 +42,7 @@
32
42
$t->get_ok('/api/insights/diurnal/do')
33
43
->status_is(200)
34
44
->json_has('/probe')
35
Removed:
->json_is('/probe' => 'do')
45
Added:
->json_is( '/probe' => 'do' )
36
46
->json_has('/hours')
37
47
->json_has('/traces')
38
48
->json_has('/mean');
@@ -47,8 +57,8 @@
47
57
subtest 'diurnal API accepts days param' => sub {
48
58
$t->get_ok('/api/insights/diurnal/ph?days=3')
49
59
->status_is(200)
50
Removed:
->json_is('/days' => 3)
51
Removed:
->json_is('/probe' => 'ph');
60
Added:
->json_is( '/days' => 3 )
61
Added:
->json_is( '/probe' => 'ph' );
52
62
};
53
63
54
64
subtest 'derivatives API returns all probes' => sub {
@@ -59,42 +69,42 @@
59
69
->json_has('/probes/do')
60
70
->json_has('/probes/orp')
61
71
->json_has('/probes/ec')
62
Removed:
->json_is('/hours' => 48);
72
Added:
->json_is( '/hours' => 48 );
63
73
};
64
74
65
75
subtest 'derivatives API accepts hours param' => sub {
66
76
$t->get_ok('/api/insights/derivatives?hours=24')
67
77
->status_is(200)
68
Removed:
->json_is('/hours' => 24);
78
Added:
->json_is( '/hours' => 24 );
69
79
};
70
80
71
81
subtest 'stability API returns points array' => sub {
72
82
$t->get_ok('/api/insights/stability')
73
83
->status_is(200)
74
84
->json_has('/points')
75
Removed:
->json_is('/hours' => 168)
76
Removed:
->json_is('/window' => 6);
85
Added:
->json_is( '/hours' => 168 )
86
Added:
->json_is( '/window' => 6 );
77
87
};
78
88
79
89
subtest 'stability API accepts hours param' => sub {
80
90
$t->get_ok('/api/insights/stability?hours=48')
81
91
->status_is(200)
82
Removed:
->json_is('/hours' => 48);
92
Added:
->json_is( '/hours' => 48 );
83
93
};
84
94
85
95
subtest 'correlation API returns points' => sub {
86
96
$t->get_ok('/api/correlation/ph/do?timeframe=year&range=1')
87
97
->status_is(200)
88
98
->json_has('/points')
89
Removed:
->json_is('/points/0/x' => '7.12')
90
Removed:
->json_is('/points/0/y' => '8.34');
99
Added:
->json_is( '/points/0/x' => '7.12' )
100
Added:
->json_is( '/points/0/y' => '8.34' );
91
101
};
92
102
93
103
subtest 'correlation API with no overlapping data' => sub {
94
104
$t->get_ok('/api/correlation/ph/ec?timeframe=year&range=1')
95
105
->status_is(200)
96
106
->json_has('/points')
97
Removed:
->json_is('/points' => []);
107
Added:
->json_is( '/points' => [] );
98
108
};
99
109
100
110
subtest 'correlation API with invalid probe' => sub {
@@ -110,20 +120,22 @@
110
120
};
111
121
112
122
subtest 'insights APIs with no recent data' => sub {
123
Added:
113
124
# test_app has data from 2026-07-05, outside default time windows
114
125
# so these return empty/minimal results gracefully
115
126
$t->get_ok('/api/insights/stability?hours=1')
116
127
->status_is(200)
117
Removed:
->json_is('/points' => []);
128
Added:
->json_is( '/points' => [] );
118
129
$t->get_ok('/api/insights/derivatives?hours=1')
119
130
->status_is(200)
120
131
->json_has('/probes');
121
132
$t->get_ok('/api/insights/diurnal/do?days=1')
122
133
->status_is(200)
123
Removed:
->json_is('/traces' => []);
134
Added:
->json_is( '/traces' => [] );
124
135
};
125
136
126
137
subtest 'insights APIs with matching data' => sub {
138
Added:
127
139
# Seed recent hourly rollups to exercise computation paths
128
140
my $now = time;
129
141
roles/dashboard/t/09-weather.t
@@ -141,6 +141,12 @@
141
141
->element_exists('canvas#chart-weather-combined')
142
142
->element_exists('canvas#chart-weather-solar')
143
143
->element_exists('canvas#chart-weather-ground')
144
Added:
->element_exists(
145
Added:
'main section[data-controls-panel][aria-labelledby="weather-controls-title"]'
146
Added:
)
147
Added:
->text_is( '#weather-controls-title', 'Weather controls' )
148
Added:
->element_exists(
149
Added:
'#weather-controls .graph-control[data-default-timeframe="day"]')
144
150
->element_exists('[data-default-timeframe="day"]');
145
151
};
146
152
roles/dashboard/templates/dashboard/graph-control.html.ep
@@ -1,49 +1,44 @@
1
1
% my $dt_attr = stash('default_timeframe') ? 'data-default-timeframe="' . stash('default_timeframe') . '"' : '';
2
Removed:
<div class="graph-control d-inline-flex gap-2 flex-nowrap align-items-center" <%== $dt_attr %>>
3
Removed:
<div class="input-group input-group-sm range-control" aria-label="Adjust range">
4
Removed:
<button
5
Removed:
class="btn btn-outline-secondary"
6
Removed:
type="button"
7
Removed:
data-range-adjustment="decrease"
8
Removed:
aria-label="Decrease range">−</button>
9
Removed:
<span
10
Removed:
class="input-group-text range-label"
11
Removed:
data-range-label
12
Removed:
aria-live="polite">1</span>
13
Removed:
<button
14
Removed:
class="btn btn-outline-secondary"
15
Removed:
type="button"
16
Removed:
data-range-adjustment="increase"
17
Removed:
aria-label="Increase range">+</button>
2
Added:
<div class="graph-control" <%== $dt_attr %>>
3
Added:
<div class="graph-control-group graph-control-range-group">
4
Added:
<span class="control-group-label" data-control-group="range">Range</span>
5
Added:
<div class="input-group input-group-sm range-control" aria-label="Adjust range">
6
Added:
<button
7
Added:
class="btn btn-outline-secondary"
8
Added:
type="button"
9
Added:
data-range-adjustment="decrease"
10
Added:
aria-label="Decrease range">−</button>
11
Added:
<span
12
Added:
class="input-group-text range-label"
13
Added:
data-range-label
14
Added:
aria-live="polite">1</span>
15
Added:
<button
16
Added:
class="btn btn-outline-secondary"
17
Added:
type="button"
18
Added:
data-range-adjustment="increase"
19
Added:
aria-label="Increase range">+</button>
20
Added:
</div>
18
21
</div>
19
Removed:
<%# Full button group for md+ screens %>
20
Removed:
<div class="btn-group timeframe-control d-none d-md-inline-flex" role="group" aria-label="Select timeframe">
21
Removed:
% for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ month => 'Monthly' ], [ year => 'Yearly' ] ) {
22
Removed:
<button
23
Removed:
class="btn btn-sm btn-outline-secondary timeframe-button"
24
Removed:
type="button"
25
Removed:
data-timeframe="<%= $timeframe->[0] %>"
26
Removed:
data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
27
Removed:
% }
28
Removed:
</div>
29
Removed:
<%# Dropdown for small screens %>
30
Removed:
<div class="dropdown d-md-none timeframe-control">
31
Removed:
<button
32
Removed:
class="btn btn-sm btn-outline-secondary dropdown-toggle timeframe-dropdown-toggle"
33
Removed:
type="button"
34
Removed:
data-bs-toggle="dropdown"
35
Removed:
aria-expanded="false"
36
Removed:
data-timeframe-dropdown-label>Daily</button>
37
Removed:
<ul class="dropdown-menu dropdown-menu-end">
38
Removed:
% for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ month => 'Monthly' ], [ year => 'Yearly' ] ) {
39
Removed:
<li>
40
Removed:
<button
41
Removed:
class="dropdown-item timeframe-button"
42
Removed:
type="button"
43
Removed:
data-timeframe="<%= $timeframe->[0] %>"
44
Removed:
data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
45
Removed:
</li>
46
Removed:
% }
47
Removed:
</ul>
22
Added:
<div class="graph-control-group">
23
Added:
<span class="control-group-label" data-control-group="timeframe">Timeframe</span>
24
Added:
<div class="dropdown timeframe-control">
25
Added:
<button
26
Added:
class="btn btn-sm btn-outline-secondary dropdown-toggle timeframe-dropdown-toggle"
27
Added:
type="button"
28
Added:
data-bs-toggle="dropdown"
29
Added:
aria-expanded="false"
30
Added:
data-timeframe-dropdown-label>Daily</button>
31
Added:
<ul class="dropdown-menu dropdown-menu-end">
32
Added:
% for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ month => 'Monthly' ], [ year => 'Yearly' ] ) {
33
Added:
<li>
34
Added:
<button
35
Added:
class="dropdown-item timeframe-button"
36
Added:
type="button"
37
Added:
data-timeframe="<%= $timeframe->[0] %>"
38
Added:
data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
39
Added:
</li>
40
Added:
% }
41
Added:
</ul>
42
Added:
</div>
48
43
</div>
49
44
</div>
roles/dashboard/templates/dashboard/graph.html.ep
@@ -2,11 +2,14 @@
2
2
% title "FAPG DAQ $probe->{label}";
3
3
% stash use_charts => 1;
4
4
5
Removed:
<section id="probe-detail" class="mb-4" aria-label="<%= $probe->{label} %> probe detail">
6
Removed:
<div id="probe-controls" class="d-flex align-items-center justify-content-end gap-2 mb-3 sticky-controls">
5
Added:
<div class="dashboard-page-layout mb-4">
6
Added:
<section id="probe-controls" class="dashboard-controls-panel sticky-controls" data-controls-panel aria-labelledby="probe-controls-title">
7
Added:
<h2 id="probe-controls-title" class="controls-panel-title"><%= $probe->{label} %> chart controls</h2>
7
8
%= include 'dashboard/graph-control', aria_label => "$probe->{label} chart timeframe", default_timeframe => 'day'
8
Removed:
</div>
9
Added:
</section>
9
10
11
Added:
<section id="probe-detail" class="dashboard-page-content" aria-label="<%= $probe->{label} %> probe detail">
12
Added:
10
13
%= include 'dashboard/probe-card', probe => $probe
11
14
12
15
<div id="latest-readings" class="card mt-3" aria-label="Last ten reading messages for <%= $probe->{label} %>">
@@ -35,4 +38,5 @@
35
38
</div>
36
39
</div>
37
40
38
Removed:
</section>
41
Added:
</section>
42
Added:
</div>
roles/dashboard/templates/dashboard/index.html.ep
@@ -2,7 +2,28 @@
2
2
% title 'FAPG DAQ Dashboard';
3
3
% stash use_charts => 1;
4
4
5
Added:
<header id="mobile-home-masthead" class="mobile-home-masthead d-md-none" data-home-masthead>
6
Added:
<div class="mobile-home-masthead-row">
7
Added:
<p class="mobile-home-brand mb-0">FAPG</p>
8
Added:
<a class="mobile-home-phone" href="tel:+33787044193" aria-label="Call FAPG at 07 87 04 41 93" data-home-phone>
9
Added:
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
10
Added:
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.9.33 1.78.62 2.64a2 2 0 0 1-.45 2.11L8.01 9.74a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.86.29 1.74.5 2.64.62A2 2 0 0 1 22 16.92z" />
11
Added:
</svg>
12
Added:
</a>
13
Added:
</div>
14
Added:
</header>
15
Added:
5
16
<section id="index-overview" class="mb-3" aria-label="DAQ status overview">
17
Added:
<figure class="card home-koan-card mb-3" aria-label="Aquaponic Zen koan" data-home-koan-card>
18
Added:
<div class="card-body">
19
Added:
<blockquote class="mb-1">
20
Added:
<p class="home-koan mb-0" data-home-koan>
21
Added:
<em class="home-koan-text"><%= $home_koan %></em>
22
Added:
</p>
23
Added:
</blockquote>
24
Added:
<figcaption class="home-koan-attribution" data-home-koan-attribution>— <%= $home_koan_master %></figcaption>
25
Added:
</div>
26
Added:
</figure>
6
27
7
28
<section id="quick-readings" class="reading-quick-status mb-3" aria-label="Sensor reading quick status">
8
29
<div class="row g-2" data-reading-quick-list>
roles/dashboard/templates/dashboard/insights.html.ep
@@ -2,11 +2,14 @@
2
2
% title 'FAPG DAQ Insights';
3
3
% stash use_charts => 1;
4
4
5
Removed:
<section id="insights-page" class="mb-4" aria-label="Insights visualisations">
6
Removed:
<div id="insights-controls" class="d-flex align-items-center justify-content-end gap-2 mb-3 sticky-controls">
5
Added:
<div class="dashboard-page-layout mb-4">
6
Added:
<section id="insights-controls" class="dashboard-controls-panel sticky-controls" data-controls-panel aria-labelledby="insights-controls-title">
7
Added:
<h2 id="insights-controls-title" class="controls-panel-title">Insights controls</h2>
7
8
%= include 'dashboard/graph-control', aria_label => 'Insights timeframe', default_timeframe => 'day'
8
Removed:
</div>
9
Added:
</section>
9
10
11
Added:
<section id="insights-page" class="dashboard-page-content" aria-label="Insights visualisations">
12
Added:
10
13
<div class="row g-3">
11
14
<div class="col-12 col-xl-6" id="stability">
12
15
<div class="card">
@@ -63,4 +66,5 @@
63
66
</div>
64
67
</div>
65
68
</div>
66
Removed:
</section>
69
Added:
</section>
70
Added:
</div>
roles/dashboard/templates/dashboard/probes.html.ep
@@ -2,16 +2,19 @@
2
2
% title 'FAPG DAQ Probes';
3
3
% stash use_charts => 1;
4
4
5
Removed:
<section id="probes-page" class="mb-4" aria-label="Probe readings">
6
Removed:
<div id="probes-controls" class="d-flex align-items-center justify-content-end gap-2 mb-3 sticky-controls">
5
Added:
<div class="dashboard-page-layout mb-4">
6
Added:
<section id="probes-controls" class="dashboard-controls-panel sticky-controls" data-controls-panel aria-labelledby="probes-controls-title">
7
Added:
<h2 id="probes-controls-title" class="controls-panel-title">Probe chart controls</h2>
7
8
%= include 'dashboard/graph-control', aria_label => 'All probe chart timeframes', default_timeframe => 'day'
8
Removed:
</div>
9
Added:
</section>
9
10
10
Removed:
<div class="row g-3 probes-grid">
11
Removed:
% for my $probe ($probes->@*) {
12
Removed:
<div class="col-12 col-xl-6">
13
Removed:
%= include 'dashboard/probe-card', probe => $probe
11
Added:
<section id="probes-page" class="dashboard-page-content" aria-label="Probe readings">
12
Added:
<div class="row g-3 probes-grid">
13
Added:
% for my $probe ($probes->@*) {
14
Added:
<div class="col-12 col-xl-6">
15
Added:
%= include 'dashboard/probe-card', probe => $probe
16
Added:
</div>
17
Added:
% }
14
18
</div>
15
Removed:
% }
16
Removed:
</div>
17
Removed:
</section>
19
Added:
</section>
20
Added:
</div>
roles/dashboard/templates/dashboard/weather.html.ep
@@ -2,11 +2,14 @@
2
2
% title 'FAPG DAQ Weather';
3
3
% stash use_charts => 1;
4
4
5
Removed:
<section id="weather-page" class="mb-4" aria-label="Weather data">
6
Removed:
<div id="weather-controls" class="d-flex align-items-center justify-content-end gap-2 mb-3 sticky-controls">
5
Added:
<div class="dashboard-page-layout mb-4">
6
Added:
<section id="weather-controls" class="dashboard-controls-panel sticky-controls" data-controls-panel aria-labelledby="weather-controls-title">
7
Added:
<h2 id="weather-controls-title" class="controls-panel-title">Weather controls</h2>
7
8
%= include 'dashboard/graph-control', aria_label => 'Weather timeframe', default_timeframe => 'day'
8
Removed:
</div>
9
Added:
</section>
9
10
11
Added:
<section id="weather-page" class="dashboard-page-content" aria-label="Weather data">
12
Added:
10
13
<div class="row g-3">
11
14
<div class="col-12 col-xl-6" id="weather-combined">
12
15
<div class="card">
@@ -41,4 +44,5 @@
41
44
</div>
42
45
</div>
43
46
</div>
44
Removed:
</section>
47
Added:
</section>
48
Added:
</div>
roles/dashboard/templates/layouts/default.html.ep
@@ -18,68 +18,42 @@
18
18
19
19
<a class="visually-hidden-focusable" href="#main-content">Skip to main content</a>
20
20
21
Removed:
<nav id="navbar" class="navbar navbar-expand-md bg-body border-bottom shadow-sm sticky-top mb-3" aria-label="Dashboard navigation">
21
Added:
% my $nav_page = stash('nav_page') // '';
22
Added:
<nav id="navbar" class="navbar navbar-expand-md d-none d-md-flex bg-body border-bottom shadow-sm sticky-top mb-3" aria-label="Dashboard navigation">
22
23
<div class="container-fluid px-3">
23
Removed:
24
24
<a id="navbar-brand" class="navbar-brand fw-bold text-success" href="/">FAPG</a>
25
25
26
Removed:
% my $page_title = stash('page_title') // '';
27
Removed:
% if ($page_title) {
28
Removed:
<span id="navbar-title" class="navbar-text fw-semibold text-body" aria-hidden="true"><%= $page_title %></span>
29
Removed:
% }
30
Removed:
31
Removed:
<div class="d-flex align-items-center gap-2 ms-auto order-md-last">
32
Removed:
<button
33
Removed:
class="navbar-toggler"
34
Removed:
type="button"
35
Removed:
data-bs-toggle="collapse"
36
Removed:
data-bs-target="#navbarLinks"
37
Removed:
aria-controls="navbarLinks"
38
Removed:
aria-expanded="false"
39
Removed:
aria-label="Toggle navigation">
40
Removed:
<span class="navbar-toggler-icon"></span>
41
Removed:
</button>
42
Removed:
</div>
43
Removed:
44
Removed:
<div class="collapse navbar-collapse" id="navbarLinks">
45
Removed:
<ul class="navbar-nav ms-auto mb-2 mb-md-0">
46
Removed:
<li class="nav-item dropdown">
47
Removed:
<a class="nav-link fw-semibold dropdown-toggle<%= (stash('nav_page') // '') eq 'probes' ? ' active' : '' %>"
48
Removed:
href="#"
49
Removed:
role="button"
50
Removed:
data-bs-toggle="dropdown"
51
Removed:
aria-expanded="false"
52
Removed:
<%= (stash('nav_page') // '') eq 'probes' ? 'aria-current="page"' : '' %>>Probes</a>
53
Removed:
<ul class="dropdown-menu">
54
Removed:
<li><a class="dropdown-item" href="/probes">Readings</a></li>
55
Removed:
<li><a class="dropdown-item" href="/probes/status">Status</a></li>
56
Removed:
</ul>
57
Removed:
</li>
58
Removed:
<li class="nav-item">
59
Removed:
% if ((stash('nav_page') // '') eq 'insights') {
60
Removed:
<a class="nav-link fw-semibold active" href="/insights" aria-current="page">Insights</a>
61
Removed:
% } else {
62
Removed:
<a class="nav-link fw-semibold" href="/insights">Insights</a>
63
Removed:
% }
64
Removed:
</li>
65
Removed:
<li class="nav-item">
66
Removed:
% if ((stash('nav_page') // '') eq 'weather') {
67
Removed:
<a class="nav-link fw-semibold active" href="/weather" aria-current="page">Weather</a>
68
Removed:
% } else {
69
Removed:
<a class="nav-link fw-semibold" href="/weather">Weather</a>
70
Removed:
% }
71
Removed:
</li>
72
Removed:
<li class="nav-item">
73
Removed:
% if ((stash('nav_page') // '') eq 'downloads') {
74
Removed:
<a class="nav-link fw-semibold active" href="/downloads" aria-current="page">Downloads</a>
75
Removed:
% } else {
76
Removed:
<a class="nav-link fw-semibold" href="/downloads">Downloads</a>
77
Removed:
% }
78
Removed:
</li>
79
Removed:
</ul>
80
Removed:
</div>
81
Removed:
26
Added:
<ul class="navbar-nav ms-auto">
27
Added:
<li class="nav-item dropdown">
28
Added:
<a class="nav-link fw-semibold dropdown-toggle<%== $nav_page eq 'probes' ? ' active' : '' %>"
29
Added:
href="#"
30
Added:
role="button"
31
Added:
data-bs-toggle="dropdown"
32
Added:
aria-expanded="false"
33
Added:
<%== $nav_page eq 'probes' ? 'aria-current="page"' : '' %>>Probes</a>
34
Added:
<ul class="dropdown-menu">
35
Added:
<li><a class="dropdown-item" href="/probes">Readings</a></li>
36
Added:
<li><a class="dropdown-item" href="/probes/status">Status</a></li>
37
Added:
</ul>
38
Added:
</li>
39
Added:
<li class="nav-item">
40
Added:
<a class="nav-link fw-semibold<%== $nav_page eq 'insights' ? ' active' : '' %>" href="/insights"<%== $nav_page eq 'insights' ? ' aria-current="page"' : '' %>>Insights</a>
41
Added:
</li>
42
Added:
<li class="nav-item">
43
Added:
<a class="nav-link fw-semibold<%== $nav_page eq 'weather' ? ' active' : '' %>" href="/weather"<%== $nav_page eq 'weather' ? ' aria-current="page"' : '' %>>Weather</a>
44
Added:
</li>
45
Added:
<li class="nav-item">
46
Added:
<a class="nav-link fw-semibold<%== $nav_page eq 'downloads' ? ' active' : '' %>" href="/downloads"<%== $nav_page eq 'downloads' ? ' aria-current="page"' : '' %>>Downloads</a>
47
Added:
</li>
48
Added:
</ul>
82
49
</div>
50
Added:
</nav>
51
Added:
52
Added:
<nav class="mobile-bottom-nav d-md-none" aria-label="Primary navigation" data-mobile-navigation>
53
Added:
<a class="mobile-bottom-nav-link<%== $nav_page eq 'home' ? ' active' : '' %>" href="/"<%== $nav_page eq 'home' ? ' aria-current="page"' : '' %>>Home</a>
54
Added:
<a class="mobile-bottom-nav-link<%== $nav_page eq 'insights' ? ' active' : '' %>" href="/insights"<%== $nav_page eq 'insights' ? ' aria-current="page"' : '' %>>Insights</a>
55
Added:
<a class="mobile-bottom-nav-link<%== $nav_page eq 'probes' ? ' active' : '' %>" href="/probes"<%== $nav_page eq 'probes' ? ' aria-current="page"' : '' %>>Probes</a>
56
Added:
<a class="mobile-bottom-nav-link<%== $nav_page eq 'weather' ? ' active' : '' %>" href="/weather"<%== $nav_page eq 'weather' ? ' aria-current="page"' : '' %>>Weather</a>
83
57
</nav>
84
58
85
59
<main id="main-content" class="container">