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.

Commit
719c0e97c67682b3239bf0f4a64a021d98c8ce23
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ARCHITECTURE.org
index 2cd885d3..2eb1fb9e 100644..100644
@@ -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
index aef1fbb5..2620914b 100644..100644
@@ -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
index a819b419..ad4dcf7e 100644..100644
@@ -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
index 65a367ca..ce2cb967 100644..100644
@@ -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
index 6ce68294..4368c0cf 100644..100644
@@ -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
index 80838ed2..79485b22 100644..100644
@@ -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
index 85945d01..6e479a86 100644..100644
@@ -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