[Racket] Ferti hydroponic nutrient solver, redux.
1
#lang racket
2
3
(provide index-page
4
ferti-index-page
5
ferti-measurements-and-rotations-page
6
ferti-recipe-page
7
ferti-fertilizers-page
8
ferti-crop-requirements-page
9
new-measurement-page
10
new-rotation-page
11
new-fertilizer-page
12
new-crop-page
13
new-crop-requirement-page
14
edit-measurement-page
15
edit-fertilizer-page
16
edit-crop-page
17
edit-crop-requirement-page
18
show-measurement-page
19
show-rotation-page
20
show-fertilizer-page
21
show-crop-page
22
show-crop-requirement-page
23
fallback-page)
24
25
(require gregor
26
web-server/formlets
27
"formlets.rkt"
28
"models/user.rkt"
29
"models/nutrient.rkt"
30
"models/nutrient-value.rkt"
31
"models/nutrient-measurement.rkt"
32
"models/crop.rkt"
33
"models/crop-rotation.rkt"
34
"models/crop-requirement.rkt"
35
"models/fertilizer-product.rkt")
36
37
(define (page-template title body-xexpr)
38
`(html (head (meta ([charset "utf-8"]))
39
(meta ([name "viewport"] [content "width=device-width, initial-scale=1"]))
40
(title ,title)
41
;; Bootstrap CSS
42
(link ([href "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"]
43
[rel "stylesheet"]
44
[integrity
45
"sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"]
46
[crossorigin "anonymous"])))
47
(body ,navbar
48
(div ((class "container")) ,@body-xexpr)
49
;; Bootstrap JS bundle
50
(script
51
([src "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"]
52
[integrity "sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"]
53
[crossorigin "anonymous"])))))
54
55
;; Page components
56
57
(define navbar
58
'(nav
59
((class "navbar navbar-expand-lg navbar-light bg-light"))
60
(div
61
((class "container-fluid"))
62
(a ((class "navbar-brand") [href "/"]) "FAPG")
63
(button ((class "navbar-toggler") [type "button"]
64
[data-bs-toggle "collapse"]
65
[data-bs-target "#navbarSupportedContent"]
66
[aria-controls "navbarSupportedContent"]
67
[aria-expanded "false"]
68
[aria-label "Toggle navigation"])
69
(span ((class "navbar-toggler-icon"))))
70
(div ((class "collapse navbar-collapse") [id "navbarSupportedContent"])
71
(ul ((class "navbar-nav me-auto mb-2 mb-lg-0"))
72
(li ((class "nav-item"))
73
(a ((class "nav-link disabled") [href "#"] [tabindex "-1"] [aria-disabled "true"])
74
"Facturation Pro"))
75
(li ((class "nav-item"))
76
(a ((class "nav-link active") [aria-current "page"] [href "/ferti/index"]) "Ferti"))
77
(li ((class "nav-item"))
78
(a ((class "nav-link disabled") [href "#"] [tabindex "-1"] [aria-disabled "true"])
79
"Inventaire"))
80
(li ((class "nav-item")) (a ((class "nav-link") [href "/contact"]) "Contact")))))))
81
82
;; Page helpers
83
84
(define (round n number)
85
(~r number #:precision `(= ,n)))
86
87
(define (normal-date date-string)
88
(~t (iso8601->date date-string) "dd/MM/yyyy"))
89
90
;; Ferti
91
92
(define (ferti-template active-tab body-xexpr)
93
(page-template (format "Ferti — ~a" active-tab) `(,(ferti-tabs-bar active-tab) ,@body-xexpr)))
94
95
(define (ferti-tabs-bar active-tab)
96
`(ul ((class "nav nav-tabs my-3"))
97
,@
98
(for/list ([tab ferti-tabs])
99
(match-define (cons tab-title tab-action) tab)
100
`(li ((class "nav-item"))
101
(a ((class ,(string-append "nav-link" (if (equal? tab-title active-tab) " active" "")))
102
(aria-current "page")
103
(href ,tab-action))
104
,tab-title)))))
105
106
(define ferti-tabs
107
'(("Accueil" . "/ferti/index") ("Relevés et Assolements" . "/ferti/measurements-and-rotations")
108
("Intrants" . "/ferti/fertilizers")
109
("Cultures" . "/ferti/crop-requirements")))
110
111
(define (ferti-index-page)
112
(ferti-template
113
"Accueil"
114
`((p "La recette Ferti© est calculée en fonction d'un relevé de nutriments et d'un assolement.")
115
(div
116
((class "btn-group-vertical"))
117
(a ((class "btn btn-outline-primary") [href "/ferti/measurements/new"]) "Ajouter un relevé")
118
(a ((class "btn btn-outline-primary") [href "/ferti/rotations/new"]) "Ajouter un assolement")
119
(a ((class "btn btn-outline-primary") [href "/ferti/fertilizers/new"]) "Ajouter un intrant")))))
120
121
(define (ferti-measurements-and-rotations-page measurements rotations)
122
(define (maybe-rotation-for-measurement m)
123
(findf (λ (r) (equal? (crop-rotation-date r) (nutrient-measurement-date m))) rotations))
124
(define table
125
`(table
126
((class "table"))
127
(thead (tr (th "Date du relevé") (th "Relevé") (th "Assolement") (th "Recette")))
128
(tbody
129
,@(for/list ([m measurements])
130
(define maybe-rotation (maybe-rotation-for-measurement m))
131
`(tr
132
(td ((class "font-monospace align-middle")) ,(normal-date (nutrient-measurement-date m)))
133
(td (a ((class "btn btn-outline-secondary btn-sm")
134
(href ,(format "/ferti/measurements/~a" (nutrient-measurement-id m))))
135
"Consulter"))
136
(td ,(if maybe-rotation
137
`(a ((class "btn btn-outline-secondary btn-sm")
138
(href ,(format "/ferti/rotations/~a" (crop-rotation-id maybe-rotation))))
139
"Consulter")
140
`(a ((class "btn btn-outline-primary btn-sm")
141
(href ,(format "/ferti/rotations/new/~a" (nutrient-measurement-date m))))
142
"Ajouter")))
143
(td ,(if maybe-rotation
144
`(a ((class "btn btn-outline-secondary btn-sm")
145
(href ,(format "/ferti/recipes/~a" (crop-rotation-date maybe-rotation))))
146
"Consulter")
147
"—")))))))
148
(ferti-template "Relevés et Assolements"
149
`((div ((class "btn-group mb-3"))
150
(a ((class "btn btn-primary") [href "/ferti/measurements/new"])
151
"Ajouter un relevé"))
152
(div ((class "overflow-auto")) ,table))))
153
154
(define (ferti-fertilizers-page fertilizers)
155
(define table
156
`(table ((class "table table-striped"))
157
(tr (th () "Nom de référence") (th () "Nom de marque"))
158
,@(for/list ([fp fertilizers])
159
(define brand-name (fertilizer-brand-name fp))
160
`(tr (td (a ([href ,(format "/ferti/fertilizers/~a" (fertilizer-product-id fp))])
161
,(fertilizer-product-name fp)))
162
(td ,(if (and brand-name (non-empty-string? brand-name)) brand-name "—"))))))
163
(ferti-template "Intrants"
164
`((a ((class "btn btn-primary mb-3") [href "/ferti/fertilizers/new"])
165
"Ajouter un intrant")
166
,table)))
167
168
(define (ferti-crop-requirements-page crop-requirements)
169
(define table
170
`(table ((class "table table-striped"))
171
(tr (th "Profil") (th "Culture"))
172
,@(for/list ([cr crop-requirements])
173
(define cid (crop-requirement-crop-id cr))
174
`(tr (td (a ((href ,(format "/ferti/crop-requirements/~a" (crop-requirement-id cr))))
175
,(string-titlecase (crop-requirement-profile cr))))
176
(td ,(if cid
177
(let ([crop (get-crop #:id cid)])
178
`(a ((href ,(format "/ferti/crops/~a" cid)))
179
,(string-titlecase (crop-name crop))))
180
"—"))))))
181
(define button-group
182
'(div ((class "btn-group mb-3"))
183
(a ((class "btn btn-primary") [href "/ferti/crop-requirements/new"]) "Ajouter un profil")
184
(a ((class "btn btn-secondary") [href "/ferti/crops/new"]) "Ajouter une culture")))
185
(ferti-template "Cultures" (list button-group table)))
186
187
;; TODO: add bar chart for comparing to target concentrations
188
(define (ferti-recipe-page recipe-date fertilizer-recipe)
189
(define normal-recipe-date (normal-date recipe-date))
190
(define title (format "Recette du ~a" normal-recipe-date))
191
(define table
192
`(table ((class "table"))
193
(thead (tr (th "Intrant") (th "Marque") (th ((class "text-end")) "Quantité (g)")))
194
(tbody ,@(for/list ([(fertilizer quantity) (in-hash fertilizer-recipe)]
195
#:when (not (zero? quantity)))
196
(define canonical-name (fertilizer-product-name fertilizer))
197
(define brand-name (fertilizer-brand-name fertilizer))
198
`(tr (td ,canonical-name)
199
(td ,(if (non-empty-string? brand-name) brand-name "—"))
200
(td ((class "text-end font-monospace")) ,(round 2 (* 100 quantity))))))))
201
(page-template title
202
`((h1 ((class "display-1 mb-3")) "Recette Ferti")
203
(h5 ((class "display-5 mb-3")) ,normal-recipe-date)
204
(p "Quantités calculées pour 100'000 L.")
205
,table)))
206
207
;; New
208
209
(define (form-page-template title action formlet)
210
(page-template title
211
`((h1 ((class "display-1 mb-3")) ,title)
212
(div ((class "mb-3") [style "max-width: 30em"])
213
(form ([action ,action] [method "POST"]) ,@(formlet-display formlet))))))
214
215
(define (new-measurement-page)
216
(form-page-template "Nouveau relevé" "/ferti/measurements/create" (measurements-formlet)))
217
218
(define (new-rotation-page #:date [date-string #f])
219
(form-page-template "Nouvel assolement"
220
"/ferti/rotations/create"
221
(rotation-formlet #:date date-string)))
222
223
(define (new-fertilizer-page)
224
(form-page-template "Nouvel intrant" "/ferti/fertilizers/create" (fertilizer-formlet)))
225
226
(define (new-crop-page)
227
(form-page-template "Nouvelle culture" "/ferti/crops/create" (crop-formlet)))
228
229
(define (new-crop-requirement-page)
230
(form-page-template "Nouveau profil" "/ferti/crop-requirements/create" (crop-requirements-formlet)))
231
232
;; Edit
233
234
(define (edit-measurement-page nm)
235
(form-page-template "Modifier le relevé"
236
"/ferti/measurements/update"
237
(measurements-formlet #:value nm)))
238
239
(define (edit-fertilizer-page fp)
240
(form-page-template "Modifier l'intrant"
241
"/ferti/fertilizers/update"
242
(fertilizer-formlet #:value fp)))
243
244
(define (edit-crop-page crop)
245
(form-page-template "Modifier la culture" "/ferti/crops/update" (crop-formlet #:value crop)))
246
247
(define (edit-crop-requirement-page cr)
248
(form-page-template "Modifier profil"
249
"/ferti/crop-requirements/update"
250
(crop-requirements-formlet #:value cr)))
251
252
;; Show
253
254
(define (show-measurement-page nm)
255
(define id (nutrient-measurement-id nm))
256
(define measurement-date (normal-date (nutrient-measurement-date nm)))
257
(define table
258
`(table ((class "table") (style "max-width: 30em"))
259
(thead (tr (th "Nutriment") (th ((class "text-end")) "Concentration (mg/L)")))
260
(tbody ,@(for/list ([n (get-nutrients)])
261
(define nutrient-value (hash-ref (nutrient-measurement-values nm) n 0))
262
`(tr (td ,(nutrient-french-name n))
263
(td ((class "text-end font-monospace")) ,(round 2 nutrient-value)))))))
264
(define button-group
265
`(div ((class "btn-group mb-3"))
266
(a ((class "btn btn-primary") [href ,(format "/ferti/measurements/~a/edit" id)]) "Modifier")
267
(a ((class "btn btn-danger") [href ,(format "/ferti/measurements/~a/destroy" id)])
268
"Supprimer")))
269
(page-template (format "Relevé du ~a" measurement-date)
270
`((h1 ((class "display-1 mb-3")) "Relevé") (h5 ((class "display-5 mb-3"))
271
,measurement-date)
272
,button-group
273
,table)))
274
275
(define (show-rotation-page cr)
276
(define id (crop-rotation-id cr))
277
(define rotation-date (normal-date (crop-rotation-date cr)))
278
(define table
279
`(table ((class "table") (style "max-width: 30em"))
280
(thead (tr (th "Type de culture") (th ((class "text-end")) "Proportion (%)")))
281
(tbody ,@(for/list ([requirement (get-crop-requirements)])
282
(define requirement-proportion
283
(hash-ref (crop-rotation-requirements cr) requirement 0))
284
`(tr (td ,(crop-requirement-profile requirement))
285
(td ((class "text-end font-monospace"))
286
,(round 2 requirement-proportion)))))))
287
(define button-group
288
`(div ((class "btn-group mb-3"))
289
(a ((class "btn btn-primary") [href ,(format "/ferti/rotations/~a/edit" id)]) "Modifier")
290
(a ((class "btn btn-danger")
291
[href ,(format "/ferti/rotations/~a/destroy" (crop-rotation-id cr))])
292
"Supprimer")))
293
(page-template (format "Assolement du ~a" rotation-date)
294
`((h1 ((class "display-1 mb-3")) ,"Assolement") (h5 ((class "display-5 mb-3"))
295
,rotation-date)
296
,button-group
297
,table)))
298
299
(define (show-fertilizer-page fp)
300
(define id (fertilizer-product-id fp))
301
(define product-name (string-titlecase (fertilizer-product-name fp)))
302
(define brand-name (fertilizer-brand-name fp))
303
(define sorted-nutrient-values (get-sorted-nutrient-values (fertilizer-product-values fp)))
304
(define table
305
`(table ((class "table") (style "max-width: 30em"))
306
(thead (tr (th "Nutriment") (th ((class "text-end")) "Concentration (mg/L)")))
307
(tbody ,@(for/list ([nv-pair sorted-nutrient-values])
308
(match-define (cons n v) nv-pair)
309
`(tr (td ,(nutrient-french-name n))
310
(td ((class "text-end font-monospace")) ,(round 2 v)))))))
311
(define button-group
312
`(div ((class "btn-group mb-3"))
313
(a ((class "btn btn-primary") [href ,(format "/ferti/fertilizers/~a/edit" id)]) "Modifier")
314
(a ((class "btn btn-danger") [href ,(format "/ferti/fertilizers/~a/destroy" id)])
315
"Supprimer")))
316
(page-template product-name
317
`((h1 ((class "display-1 mb-3")) ,product-name)
318
(h5 ((class "display-5 mb-3"))
319
,(if (non-empty-string? brand-name) brand-name "Intrant générique"))
320
,button-group
321
,table)))
322
323
(define (show-crop-page crop)
324
(define id (crop-id crop))
325
(define name (string-titlecase (crop-name crop)))
326
(define crop-requirements-for-crop
327
(filter (λ (cr) (equal? (crop-requirement-crop-id cr) (crop-id crop))) (get-crop-requirements)))
328
(define profile-list
329
`(ul ,@(for/list ([cr crop-requirements-for-crop])
330
`(li (a ([href ,(format "/ferti/crop-requirements/~a" (crop-requirement-id cr))])
331
,(string-titlecase (crop-requirement-profile cr)))))))
332
(define button-group
333
`(div ((class "btn-group mb-3"))
334
(a ((class "btn btn-primary") [href ,(format "/ferti/crops/~a/edit" id)]) "Modifier")
335
(a ((class "btn btn-danger") [href ,(format "/ferti/crops/~a/destroy" id)]) "Supprimer")))
336
(page-template name `((h1 ((class "display-1 mb-3")) ,name) ,button-group ,profile-list)))
337
338
(define (show-crop-requirement-page cr)
339
(define id (crop-requirement-id cr))
340
(define cid (crop-requirement-crop-id cr))
341
(define crop
342
(if cid
343
(string-titlecase (crop-name (get-crop #:id cid)))
344
#f))
345
(define profile (string-titlecase (crop-requirement-profile cr)))
346
(define title
347
(if crop
348
(format "~a — ~a" crop profile)
349
profile))
350
(define table
351
`(table ((class "table") (style "max-width: 30em"))
352
(thead (tr (th "Nutriment") (th ((class "text-end")) "Concentration (mg/L)")))
353
(tbody ,@(for/list ([n (get-nutrients)])
354
(define nutrient-value (hash-ref (crop-requirement-values cr) n 0))
355
`(tr (td ,(nutrient-french-name n))
356
(td ((class "text-end font-monospace")) ,(round 2 nutrient-value)))))))
357
(define button-group
358
`(div ((class "btn-group"))
359
(a ((class "btn btn-primary") [href ,(format "/ferti/crop-requirements/~a/edit" id)])
360
"Modifier")
361
(a ((class "btn btn-danger") [href ,(format "/ferti/crop-requirements/~a/destroy" id)])
362
"Supprimer")))
363
(page-template title
364
`((h1 ((class "display-1 mb-3")) ,profile) (h5 ((class "display-5 mb-3"))
365
,(or crop "Profil générique"))
366
,button-group
367
,table)))
368
369
(define (index-page user)
370
(page-template
371
"Bienvenue à la FAPG"
372
`((div ((class "jumbotron"))
373
(h1 ((class "display-1 mb-3"))
374
,(string-join (list (if (<= (->hours (current-time)) 17) "Bonjour" "Bonsoir")
375
(if user
376
(user-name user)
377
"et bienvenue"))))
378
(hr ((class "my-3")))
379
(p ((class "lead")) ,(~t (now) "'Dernière connexion à' HH:mm, 'le' EEEE d MMMM yyyy"))
380
(p ((class "lead"))
381
(a ((class "btn btn-primary mb-3") [href "/ferti/index"]) "Accéder à Ferti"))))))
382
383
(define (fallback-page request-code)
384
(page-template (format "Réponse: ~a" request-code)
385
`((h1 ((class "display-1 text-danger")) ,(number->string request-code))
386
(p ,(fallback-message request-code))
387
(a ([href "/"]) "Revenir à la page d'accueil"))))
388
389
(define (fallback-message request-code)
390
(string-join
391
`("Bonjour, je suis votre serveur."
392
,(format "J'ai répondu '~a'" request-code)
393
"et"
394
,(case (string-ref (number->string request-code) 0)
395
[(#\4) "c'est de votre faute."]
396
[(#\5) "c'est de ma faute."]
397
[else "je ne sais pas qui est en tort."])
398
,(case request-code
399
;; Client errors
400
[(400) "Votre requête ne fait pas sens."]
401
[(401) "Vous n'avez pas vérifié votre identité."]
402
[(403) "Vous n'avez pas le droit de consulter cette page."]
403
[(404) "Vous avez demandé de consulter une page qui n'existe pas."]
404
;; Server errors
405
[(500)
406
"Je suis dans une situation que je ne sais pas gérer, et ne peux vous en dire davantage."]
407
[(502)
408
"Un tiers ne m'a pas transmis les informations nécessaires pour répondre à votre requête."]
409
[(503)
410
"Je ne peux pas vous aider, il se peut que je sois momentanément surchargé. Revenez plus tard."]
411
;; Fallback message
412
[else (format "Je ne sais pas encore interpréter le code ~a." request-code)]))))
413