feat Show the routine as a timeline

The routine page listed workouts as flat headings. It did not show the rest that gates each workout. HD1, "Important Points", item 8, makes recovery part of the cycle, so the page now shows it. Each workout is a native details element that opens and closes with the same animation as the logging accordion. Stops open independently, so the trainee can compare workouts. The rest between workouts stays visible when a stop is closed. The last rest names the workout that starts the cycle again. The next workout is open and marked. While the trainee recovers, its summary shows the same recovery status as Home. Exercises are an ordered list, because order is part of the prescription. The rest is the authored rest from Routine.recovery_after. It does not include the extra rest day that a stall remedy adds. This matches what Service.readiness uses today. Interface changes: View_model.Routine.workout gains rest_after and position. Present.rest phrases the rest. A pre-exhaust pair reads "X into Y" on the routine page, as on the workout page. "Browse routines" is a plain link under the title.

Commit
0d2cf9df18fe8896b40a51f95418c11d4b4e371e
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/pages_routine.ml
index 6d777a2f..bbfdbc6b 100644..100644
@@ -153,34 +153,63 @@
153 153 ];
154 154 ]
155 155
156 Added: (* One workout stop on the timeline. The workout is a native [details], so it
157 Added: works without script and needs no [name]: stops open independently and the
158 Added: trainee can compare workouts. Only the next workout starts open. The rest
159 Added: stays outside the [details], so a closed timeline still shows the gating. *)
160 Added: let workout_stop (workout : View_model.Routine.workout) =
161 Added: let next, recovery =
162 Added: match workout.position with
163 Added: | View_model.Routine.Next { recovery } -> (true, recovery)
164 Added: | View_model.Routine.Elsewhere -> (false, None)
165 Added: in
166 Added: tag "li"
167 Added: ([ class_ "routine-stop" ]
168 Added: @ if next then [ Dream_html.string_attr "aria-current" "step" ] else [])
169 Added: [
170 Added: tag "details"
171 Added: ([ class_ "routine-workout" ]
172 Added: @ if next then [ Dream_html.attr "open" ] else [])
173 Added: [
174 Added: tag "summary" []
175 Added: ([ tag "h2" [ class_ "routine-day" ] [ txt "%s" workout.name ] ]
176 Added: @ (if next then
177 Added: [ tag "span" [ class_ "routine-next" ] [ txt "Next" ] ]
178 Added: else [])
179 Added: @
180 Added: match recovery with
181 Added: | Some status ->
182 Added: [ tag "span" [ class_ "routine-recovery" ] [ txt "%s" status ] ]
183 Added: | None -> []);
184 Added: (* The panel clips the content while the unfold animates. *)
185 Added: tag "div"
186 Added: [ class_ "routine-panel" ]
187 Added: [
188 Added: tag "ol"
189 Added: [ class_ "routine-exercises" ]
190 Added: (List.map
191 Added: (fun (exercise : View_model.Routine.exercise) ->
192 Added: tag "li" []
193 Added: [
194 Added: tag "span"
195 Added: [ class_ "routine-exercise" ]
196 Added: [ txt "%s" exercise.name ];
197 Added: ])
198 Added: workout.exercises);
199 Added: ];
200 Added: ];
201 Added: tag "p" [ class_ "routine-rest" ] [ txt "%s" workout.rest_after ];
202 Added: ]
203 Added:
156 204 let routine request ?workout_started ~viewer (vm : View_model.Routine.t) =
157 205 html_page ~viewer ~request ~active:"routine" ?workout_started "Routine"
158 Removed: (tag "h1" [] [ txt "%s" vm.name ]
159 Removed: :: tag "p"
160 Removed: [ class_ "button-group" ]
161 Removed: [
162 Removed: tag "a"
163 Removed: [
164 Removed: href Routes.routines;
165 Removed: class_ "button";
166 Removed: Dream_html.attr "data-hito-app-link";
167 Removed: ]
168 Removed: [ txt "Browse routines" ];
169 Removed: ]
170 Removed: :: List.concat_map
171 Removed: (fun (workout : View_model.Routine.workout) ->
172 Removed: [
173 Removed: tag "h2" [ class_ "routine-day" ] [ txt "%s" workout.name ];
174 Removed: tag "ul"
175 Removed: [ class_ "routine-exercises" ]
176 Removed: (List.map
177 Removed: (fun (exercise : View_model.Routine.exercise) ->
178 Removed: tag "li" []
179 Removed: [
180 Removed: tag "span"
181 Removed: [ class_ "routine-exercise" ]
182 Removed: [ txt "%s" exercise.name ];
183 Removed: ])
184 Removed: workout.exercises);
185 Removed: ])
186 Removed: vm.workouts)
206 Added: [
207 Added: tag "h1" [] [ txt "%s" vm.name ];
208 Added: tag "p" []
209 Added: [
210 Added: tag "a"
211 Added: [ href Routes.routines; Dream_html.attr "data-hito-app-link" ]
212 Added: [ txt "Browse routines" ];
213 Added: ];
214 Added: tag "ol" [ class_ "routine-timeline" ] (List.map workout_stop vm.workouts);
215 Added: ]
lib/presenter/view_model.ml
index 9bb10267..7e32a686 100644..100644
@@ -43,11 +43,25 @@
43 43 type exercise = { name : string }
44 44 (** One prescribed exercise line, its name already phrased. *)
45 45
46 Removed: type workout = { name : string; exercises : exercise list }
46 Added: type position =
47 Added: | Elsewhere
48 Added: | Next of { recovery : string option }
49 Added: (** The workout the cycle reaches next. [recovery] is the phrased
50 Added: recovery status while the trainee still recovers. *)
47 51
52 Added: type workout = {
53 Added: name : string;
54 Added: exercises : exercise list;
55 Added: rest_after : string;
56 Added: (** The phrased rest that follows the workout. The last workout's rest
57 Added: names the workout that starts the cycle again. *)
58 Added: position : position;
59 Added: }
60 Added:
48 61 type t = { name : string; workouts : workout list }
49 Removed: (** A routine as the accordion descriptions render it: exercise names and
50 Removed: workout grouping only. No rep bands, no [Prescription] entity. *)
62 Added: (** A routine as the timeline renders it: workouts in cycle order, each with
63 Added: the rest that gates the next one. No rep bands, no [Prescription] entity.
64 Added: *)
51 65 end
52 66
53 67 module Routine_choice = struct
lib/web/assets/hito.css
index b62c6b1b..d83f55ef 100644..100644
@@ -1142,7 +1142,8 @@
1142 1142 to visible when an open starts, and to hidden when a close ends. Browsers
1143 1143 without ::details-content ignore all of this and snap. Reduced motion also
1144 1144 snaps. */
1145 Removed: .slot-accordion::details-content {
1145 Added: .slot-accordion::details-content,
1146 Added: .routine-workout::details-content {
1146 1147 content-visibility: visible;
1147 1148 display: grid;
1148 1149 grid-template-rows: 0fr;
@@ -1151,19 +1152,22 @@
1151 1152 grid-template-rows var(--duration-base) ease,
1152 1153 visibility var(--duration-base);
1153 1154 }
1154 Removed: .slot-accordion[open]::details-content {
1155 Added: .slot-accordion[open]::details-content,
1156 Added: .routine-workout[open]::details-content {
1155 1157 grid-template-rows: 1fr;
1156 1158 visibility: visible;
1157 1159 }
1158 1160 /* The panel clips the content while the grid row animates. The padding must
1159 1161 sit on an inner body, because a padded panel cannot shrink to zero height. */
1160 Removed: .slot-accordion > .slot-panel {
1162 Added: .slot-accordion > .slot-panel,
1163 Added: .routine-workout > .routine-panel {
1161 1164 min-height: 0;
1162 1165 overflow: clip;
1163 1166 }
1164 1167 .slot-panel-body { padding: 0 1rem 1rem; }
1165 1168 @media (prefers-reduced-motion: reduce) {
1166 Removed: .slot-accordion::details-content { transition: none; }
1169 Added: .slot-accordion::details-content,
1170 Added: .routine-workout::details-content { transition: none; }
1167 1171 }
1168 1172
1169 1173 /* The elapsed workout timer occupies the middle navigation slot while
@@ -1305,21 +1309,93 @@
1305 1309 }
1306 1310 li li { color: var(--muted-ink); font-size: 0.92rem; }
1307 1311
1308 Removed: /* The routine's workouts as flat subheadings and lists. Each workout is one
1309 Removed: day heading followed by its prescribed exercises. There is too little content
1310 Removed: per day to warrant an accordion. */
1312 Added: /* The routine as a vertical timeline. Each workout is a [details] stop. The
1313 Added: rest that gates the next workout sits between stops and stays visible when a
1314 Added: stop is closed. The rail is decoration. The rest is also real text. */
1315 Added: .routine-timeline {
1316 Added: margin: 1.25rem 0 0;
1317 Added: padding: 0;
1318 Added: list-style: none;
1319 Added: }
1320 Added: .routine-stop {
1321 Added: position: relative;
1322 Added: margin: 0;
1323 Added: padding: 0 0 0 1.5rem;
1324 Added: }
1325 Added: .routine-stop::before {
1326 Added: content: "";
1327 Added: position: absolute;
1328 Added: top: 1.1rem;
1329 Added: bottom: 0;
1330 Added: left: 0.4rem;
1331 Added: border-left: 2px solid var(--rule);
1332 Added: }
1333 Added: .routine-stop::after {
1334 Added: content: "";
1335 Added: position: absolute;
1336 Added: top: 0.8rem;
1337 Added: left: 0;
1338 Added: width: 0.55rem;
1339 Added: height: 0.55rem;
1340 Added: border: 2px solid var(--muted-ink);
1341 Added: border-radius: 50%;
1342 Added: background: var(--paper);
1343 Added: }
1344 Added: .routine-stop[aria-current="step"]::after {
1345 Added: border-color: var(--brass);
1346 Added: background: var(--brass);
1347 Added: }
1348 Added: .routine-workout {
1349 Added: border: 1px solid var(--rule);
1350 Added: border-radius: var(--radius);
1351 Added: background: var(--paper-raised);
1352 Added: }
1353 Added: .routine-workout > summary {
1354 Added: display: flex;
1355 Added: flex-wrap: wrap;
1356 Added: align-items: baseline;
1357 Added: gap: 0.2rem 0.75rem;
1358 Added: padding: 0.8rem 1rem;
1359 Added: cursor: pointer;
1360 Added: }
1361 Added: .routine-workout > summary::after {
1362 Added: margin-left: auto;
1363 Added: content: "▸";
1364 Added: color: var(--muted-ink);
1365 Added: }
1366 Added: .routine-workout[open] > summary::after { content: "▾"; }
1311 1367 .routine-day {
1312 Removed: margin: 1.5rem 0 0.5rem;
1368 Added: margin: 0;
1313 1369 font-size: var(--font-size-h3);
1370 Added: line-height: 1.25;
1314 1371 }
1372 Added: .routine-next {
1373 Added: padding: 0.05rem 0.5rem;
1374 Added: border: 1px solid var(--brass);
1375 Added: border-radius: var(--radius);
1376 Added: font-size: 0.85rem;
1377 Added: font-weight: 600;
1378 Added: }
1379 Added: .routine-recovery {
1380 Added: flex-basis: 100%;
1381 Added: color: var(--muted-ink);
1382 Added: font-size: 0.92rem;
1383 Added: }
1315 1384 .routine-exercises {
1316 1385 margin: 0;
1317 Removed: padding-left: 1.2rem;
1386 Added: padding: 0 1rem 0.8rem 2.4rem;
1318 1387 }
1319 1388 .routine-exercises li {
1320 1389 padding: 0.35rem 0;
1321 1390 }
1322 1391 .routine-exercise { color: var(--ink); }
1392 Added: .routine-rest {
1393 Added: margin: 0;
1394 Added: padding: 0.9rem 0 0.9rem 0.25rem;
1395 Added: color: var(--muted-ink);
1396 Added: font-size: 0.92rem;
1397 Added: }
1398 Added: .routine-stop:last-child::before { bottom: auto; height: 0; }
1323 1399
1324 1400 @media (max-width: 42rem) {
1325 1401 .masthead {
lib/web/handlers_kit.ml
index eceb0bcf..35dc3453 100644..100644
@@ -145,6 +145,14 @@
145 145 Printf.sprintf "Recovery: %s of %s." (duration_hours rested)
146 146 (duration_hours recommended)
147 147
148 Added: (* The authored rest after a workout, in whole hours. [then_workout] names
149 Added: the workout that starts the cycle again, after the last rest only. *)
150 Added: let rest ?then_workout duration =
151 Added: let hours = Recovery.duration_to_seconds duration / 3600 in
152 Added: match then_workout with
153 Added: | None -> Printf.sprintf "Rest %d hours" hours
154 Added: | Some name -> Printf.sprintf "Rest %d hours, then %s" hours name
155 Added:
148 156 (* The catalog name when the id still resolves, else the id itself. *)
149 157 let exercise_name (id : Exercise.id) =
150 158 match Exercise.find_id (id :> string) with
lib/web/handlers_overview.ml
index 652fb5ca..d9dce659 100644..100644
@@ -25,15 +25,32 @@
25 25 match Prescription.Stimulus.delivery prescription with
26 26 | Prescription.Stimulus.Single exercise -> Exercise.name exercise
27 27 | Prescription.Stimulus.Pre_exhaust { isolation; compound } ->
28 Removed: Printf.sprintf "%s into %s, no pause" (Exercise.name isolation)
28 Added: Printf.sprintf "%s into %s" (Exercise.name isolation)
29 29 (Exercise.name compound)
30 30
31 Removed: let routine_view r : View_model.Routine.t =
31 Added: (* [next] is the workout the cycle reaches. [recovery] is the phrased status
32 Added: while the trainee recovers. The rest is the authored one, as the plan
33 Added: states it. *)
34 Added: let routine_view ?next ?recovery r : View_model.Routine.t =
35 Added: let workouts = Prescription.Routine.workouts r in
36 Added: let first = List.hd workouts
37 Added: and last = List.nth workouts (List.length workouts - 1) in
38 Added: let is_next w =
39 Added: Option.fold ~none:false
40 Added: ~some:(fun n ->
41 Added: String.equal
42 Added: (Prescription.Workout.name n)
43 Added: (Prescription.Workout.name w))
44 Added: next
45 Added: in
32 46 {
33 47 name = Prescription.Routine.name r;
34 48 workouts =
35 49 List.map
36 50 (fun w : View_model.Routine.workout ->
51 Added: let then_workout =
52 Added: if w == last then Some (Prescription.Workout.name first) else None
53 Added: in
37 54 {
38 55 name = Prescription.Workout.name w;
39 56 exercises =
@@ -41,8 +58,14 @@
41 58 (fun s : View_model.Routine.exercise ->
42 59 { name = exercise_string s })
43 60 (Prescription.Workout.stimuli w);
61 Added: rest_after =
62 Added: Present.rest ?then_workout
63 Added: (Prescription.Routine.recovery_after r w);
64 Added: position =
65 Added: (if is_next w then View_model.Routine.Next { recovery }
66 Added: else View_model.Routine.Elsewhere);
44 67 })
45 Removed: (Prescription.Routine.workouts r);
68 Added: workouts;
46 69 }
47 70
48 71 (* The most recent observed effort for an exercise. History is oldest first,
@@ -179,11 +202,24 @@
179 202 let viewer = viewer_of trainee in
180 203 Service.in_progress t.service trainee.Trainee.id >>= fun in_progress ->
181 204 Service.active_routine t.service trainee.Trainee.id >>= function
182 Removed: | Some (_, routine) ->
205 Added: | Some (id, routine) ->
206 Added: (* A failed lookup only drops the marker. The plan still renders. *)
207 Added: Service.next_workout t.service trainee.Trainee.id ~routine:id
208 Added: >>= fun next ->
209 Added: Service.readiness t.service trainee.Trainee.id ~routine:id
210 Added: ~now:(t.now ())
211 Added: >>= fun readiness ->
212 Added: let recovery =
213 Added: match readiness with
214 Added: | Ok (Recovery.Recovering { rested; recommended }) ->
215 Added: Some (Present.recovery_status ~rested ~recommended)
216 Added: | Ok Recovery.Ready | Error _ -> None
217 Added: in
183 218 html
184 219 (Pages.routine request
185 220 ?workout_started:(workout_started in_progress)
186 Removed: ~viewer (routine_view routine))
221 Added: ~viewer
222 Added: (routine_view ?next:(Result.to_option next) ?recovery routine))
187 223 | None -> redirect_to request Routes.routines
188 224
189 225 let routes t =
test/web/overview.ml
index 4559f2cd..84353fc1 100644..100644
@@ -142,6 +142,25 @@
142 142 Alcotest.(check int)
143 143 "routine browser is available" 200
144 144 (status (get c "/routines")) );
145 Added: ( "the routine page shows the rest between workouts and the next one",
146 Added: `Quick,
147 Added: fun () ->
148 Added: let c = client () in
149 Added: let _ = sign_in_new c in
150 Added: let token = Option.get (csrf_token (body (get c "/"))) in
151 Added: let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
152 Added: let page = body (get c "/routine") in
153 Added: let has needle = Option.is_some (index_from ~needle page 0) in
154 Added: Alcotest.(check bool)
155 Added: "rest between workouts is stated" true (has "Rest 48 hours");
156 Added: Alcotest.(check bool)
157 Added: "the last rest leads back to the first workout" true
158 Added: (has "Rest 72 hours, then Day 1");
159 Added: Alcotest.(check bool)
160 Added: "the next workout is marked" true (has ">Next<");
161 Added: Alcotest.(check bool)
162 Added: "the trainee can reach the routine browser" true
163 Added: (has "href=\"/routines\"") );
145 164 ( "selecting an unknown routine is not found and selects nothing",
146 165 `Quick,
147 166 fun () ->