[OCaml] High Intensity Training Online
feat Separate settings and add theme toggle
Give Profile and Settings separate endpoints. Add a session-scoped Settings page that toggles light and dark themes, expose the selected theme on the HTML root, and add dark theme variables. Update the profile menu, routes, behavior tests, and architecture documentation.
Changed files
ARCHITECTURE.org
@@ -436,6 +436,8 @@
436
436
| =/profile= | GET | Account profile: rename and change password |
437
437
| =/profile/username= | POST | Change the username |
438
438
| =/profile/password= | POST | Change the password (verifies the current one) |
439
Added:
| =/settings= | GET | Theme settings |
440
Added:
| =/settings/theme= | POST | Change the session theme |
439
441
| =/profile/import= | POST | Upload a native Hevy CSV export |
440
442
| =/profile/import/:id= | GET | Review one provisional import batch |
441
443
| =/profile/import/:id/workouts/:workout/map= | POST | Confirm one exercise mapping |
lib/web/assets/hito.css
@@ -27,6 +27,25 @@
27
27
28
28
* { box-sizing: border-box; }
29
29
30
Added:
html[data-theme="dark"] {
31
Added:
color-scheme: dark;
32
Added:
--paper: #171614;
33
Added:
--paper-raised: #24211d;
34
Added:
--ink: #f3eee3;
35
Added:
--muted-ink: #c2b9aa;
36
Added:
--rule: #655e54;
37
Added:
--rule-strong: #918778;
38
Added:
--oxblood: #c65a50;
39
Added:
--oxblood-dark: #f08073;
40
Added:
--brass: #d4a94f;
41
Added:
--brass-wash: #40351f;
42
Added:
--evergreen: #74c49a;
43
Added:
--evergreen-wash: #244334;
44
Added:
--focus: #f08073;
45
Added:
--error: #f08073;
46
Added:
}
47
Added:
48
Added:
30
49
html { background: var(--paper); }
31
50
32
51
body {
@@ -769,6 +788,16 @@
769
788
stroke-linecap: round;
770
789
}
771
790
/* One hue per factor, drawn in document order to match the legend. */
791
Added:
792
Added:
.settings-section {
793
Added:
max-width: 32rem;
794
Added:
margin-top: 1.25rem;
795
Added:
padding: 1rem;
796
Added:
border: 1px solid var(--rule);
797
Added:
border-radius: var(--radius);
798
Added:
background: var(--paper-raised);
799
Added:
}
800
Added:
.settings-form { margin-top: 1rem; }
772
801
.feedback-graph-line[data-factor="Sleep"] { stroke: var(--oxblood); }
773
802
.feedback-graph-line[data-factor="Appetite"] { stroke: var(--brass); }
774
803
.feedback-graph-line[data-factor="Readiness"] { stroke: #2f5d50; }
lib/web/handlers.ml
@@ -1236,6 +1236,53 @@
1236
1236
]
1237
1237
end
1238
1238
1239
Added:
module Settings = struct
1240
Added:
let theme_session_key = "hito.theme"
1241
Added:
1242
Added:
let theme request =
1243
Added:
match Dream.session_field request theme_session_key with
1244
Added:
| Some "dark" -> "dark"
1245
Added:
| _ -> "light"
1246
Added:
1247
Added:
let show t trainee request =
1248
Added:
let viewer =
1249
Added:
{
1250
Added:
View_model.Viewer.username =
1251
Added:
Trainee.username_to_string trainee.Trainee.username;
1252
Added:
}
1253
Added:
in
1254
Added:
Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
1255
Added:
html
1256
Added:
(Pages.settings request
1257
Added:
~logging:(Option.is_some in_progress)
1258
Added:
~viewer ~theme:(theme request) ())
1259
Added:
1260
Added:
let update _trainee request =
1261
Added:
guard_csrf request >>= function
1262
Added:
| Error _ ->
1263
Added:
redirect_with_flash request Routes.settings Present.form_invalid
1264
Added:
| Ok () -> (
1265
Added:
Dream.form request >>= function
1266
Added:
| `Ok fields -> (
1267
Added:
match List.assoc_opt "theme" fields with
1268
Added:
| Some (("light" | "dark") as value) ->
1269
Added:
Dream.set_session_field request theme_session_key value
1270
Added:
>>= fun () -> Dream.redirect request "/settings"
1271
Added:
| _ ->
1272
Added:
redirect_with_flash request Routes.settings
1273
Added:
Present.form_invalid)
1274
Added:
| _ ->
1275
Added:
redirect_with_flash request Routes.settings Present.form_invalid)
1276
Added:
1277
Added:
let routes t =
1278
Added:
[
1279
Added:
Dream_html.get Routes.settings (fun request ->
1280
Added:
authenticated t request (fun trainee -> show t trainee request));
1281
Added:
Dream_html.post Routes.settings_theme (fun request ->
1282
Added:
authenticated t request (fun trainee -> update trainee request));
1283
Added:
]
1284
Added:
end
1285
Added:
1239
1286
module Import = struct
1240
1287
let max_source_bytes = 10 * 1024 * 1024
1241
1288
@@ -1574,7 +1621,7 @@
1574
1621
let routes t =
1575
1622
Auth.routes t @ Overview.routes t @ Current_workout.routes t
1576
1623
@ Logbook.routes t @ App_feedback.routes t @ Profile.routes t
1577
Removed:
@ Import.routes t @ Assets.routes @ [ catch_all ]
1624
Added:
@ Settings.routes t @ Import.routes t @ Assets.routes @ [ catch_all ]
1578
1625
1579
1626
(* A branded error page for every failure Dream routes here: unmatched paths,
1580
1627
[4xx] and [5xx] responses, and uncaught exceptions. The page names only the
lib/web/pages.ml
@@ -22,9 +22,19 @@
22
22
(* The shell. [viewer] and [request] are present on authenticated pages, which
23
23
then show a logout control and the username on the history link. Auth pages
24
24
omit both. *)
25
Added:
let theme_session_key = "hito.theme"
26
Added:
25
27
let html_page ?viewer ?request ?(active = "") ?(logging = false)
26
28
?(show_nav = false) title content =
27
29
let spa_client = true in
30
Added:
let theme =
31
Added:
match request with
32
Added:
| Some request -> (
33
Added:
match Dream.session_field request theme_session_key with
34
Added:
| Some "dark" -> "dark"
35
Added:
| _ -> "light")
36
Added:
| None -> "light"
37
Added:
in
28
38
let nav_link page path label =
29
39
let attrs = [ href path ] in
30
40
let attrs =
@@ -75,7 +85,8 @@
75
85
tag "a" attrs [ txt "%s" label ]
76
86
in
77
87
let summary_attrs =
78
Removed:
if String.equal "profile" active then
88
Added:
if String.equal "profile" active || String.equal "settings" active
89
Added:
then
79
90
[ class_ "profile"; Dream_html.string_attr "aria-current" "page" ]
80
91
else [ class_ "profile" ]
81
92
in
@@ -93,7 +104,7 @@
93
104
]
94
105
[
95
106
menu_link "profile" Routes.profile "Profile";
96
Removed:
menu_link "profile" Routes.profile "Settings";
107
Added:
menu_link "settings" Routes.settings "Settings";
97
108
menu_link "app-feedback" Routes.app_feedback "Feedback";
98
109
tag "form"
99
110
[
@@ -167,8 +178,11 @@
167
178
| None -> []
168
179
in
169
180
tag "html"
170
Removed:
[ Dream_html.string_attr "lang" "en" ]
171
181
[
182
Added:
Dream_html.string_attr "lang" "en";
183
Added:
Dream_html.string_attr "data-theme" "%s" theme;
184
Added:
]
185
Added:
[
172
186
tag "head" []
173
187
[
174
188
tag "title" [] [ txt "Hito — %s" title ];
@@ -1851,6 +1865,38 @@
1851
1865
];
1852
1866
];
1853
1867
])
1868
Added:
1869
Added:
(* The settings page. It currently stores the user's light or dark theme choice
1870
Added:
in the session. *)
1871
Added:
let settings request ?(logging = false) ~viewer ~theme () =
1872
Added:
let next_theme, label =
1873
Added:
match theme with
1874
Added:
| "dark" -> ("light", "Use light theme")
1875
Added:
| _ -> ("dark", "Use dark theme")
1876
Added:
in
1877
Added:
html_page ~viewer ~request ~active:"settings" ~logging "Settings"
1878
Added:
[
1879
Added:
tag "h1" [] [ txt "Settings" ];
1880
Added:
tag "section"
1881
Added:
[ class_ "settings-section" ]
1882
Added:
[
1883
Added:
tag "h2" [] [ txt "Theme" ];
1884
Added:
tag "p" [] [ txt "Choose a light or dark theme for this session." ];
1885
Added:
tag "form"
1886
Added:
[ action Routes.settings_theme; post_form; class_ "settings-form" ]
1887
Added:
[
1888
Added:
Dream_html.csrf_tag request;
1889
Added:
void "input"
1890
Added:
[
1891
Added:
type_ "hidden";
1892
Added:
name "theme";
1893
Added:
Dream_html.string_attr "value" "%s" next_theme;
1894
Added:
];
1895
Added:
void "input"
1896
Added:
[ type_ "submit"; Dream_html.string_attr "value" "%s" label ];
1897
Added:
];
1898
Added:
];
1899
Added:
]
1854
1900
1855
1901
(* The review of a provisional import batch. It renders the parse warnings, then
1856
1902
each workout with its sets, blockers, and forms. Every form is a plain POST
lib/web/pages.mli
@@ -80,6 +80,15 @@
80
80
an independent form. [username_error] and [password_error] report a refused
81
81
change inline. [notice] confirms a successful one. *)
82
82
83
Added:
val settings :
84
Added:
Dream.request ->
85
Added:
?logging:bool ->
86
Added:
viewer:View_model.Viewer.t ->
87
Added:
theme:string ->
88
Added:
unit ->
89
Added:
page
90
Added:
(** The session-scoped theme settings page. *)
91
Added:
83
92
val import_review :
84
93
Dream.request ->
85
94
viewer:View_model.Viewer.t ->
lib/web/routes.ml
@@ -15,6 +15,8 @@
15
15
let%path cancel_workout = "/workout/cancel"
16
16
let%path logbook = "/logbook"
17
17
let%path profile = "/profile"
18
Added:
let%path settings = "/settings"
19
Added:
let%path settings_theme = "/settings/theme"
18
20
let%path profile_username = "/profile/username"
19
21
let%path profile_password = "/profile/password"
20
22
let%path import_upload = "/profile/import"
test/test_web.ml
@@ -1238,6 +1238,31 @@
1238
1238
let new_pw = sign_in c ~username:"lifter" ~password:"newsecret1" in
1239
1239
Alcotest.(check int)
1240
1240
"the new password authenticates" 303 (status new_pw) );
1241
Added:
( "the settings page is separate from the profile page",
1242
Added:
`Quick,
1243
Added:
fun () ->
1244
Added:
let c = client () in
1245
Added:
let _ = sign_in_new c in
1246
Added:
Alcotest.(check int)
1247
Added:
"settings served" 200
1248
Added:
(status (get c "/settings"));
1249
Added:
Alcotest.(check int)
1250
Added:
"profile served separately" 200
1251
Added:
(status (get c "/profile")) );
1252
Added:
( "the settings theme form redirects after a valid update",
1253
Added:
`Quick,
1254
Added:
fun () ->
1255
Added:
let c = client () in
1256
Added:
let _ = sign_in_new c in
1257
Added:
let token = Option.get (csrf_token (body (get c "/settings"))) in
1258
Added:
let updated =
1259
Added:
post c "/settings/theme"
1260
Added:
[ ("dream.csrf", token); ("theme", "dark") ]
1261
Added:
in
1262
Added:
Alcotest.(check int) "theme update redirects" 303 (status updated);
1263
Added:
Alcotest.(check string)
1264
Added:
"returns to settings" "/settings"
1265
Added:
(List.hd (Dream.headers updated "Location")) );
1241
1266
] );
1242
1267
( "web.errors",
1243
1268
[
@@ -1252,9 +1277,6 @@
1252
1277
( "the 404 response never depends on a session",
1253
1278
`Quick,
1254
1279
fun () ->
1255
Removed:
(* No sign-in: an unauthenticated visitor still gets a 404, not a
1256
Removed:
redirect to sign-in. The branded page is not routed through the
1257
Removed:
session guard. *)
1258
1280
let c = client () in
1259
1281
Alcotest.(check int)
1260
1282
"unauthenticated 404" 404