Repository navigation
Add workout image to website - placeholder #472
Description
Original description
Generate interval workout that's the same length as demo route. Need to fudge workout loading with test as functionality isn't available for logged out users/demo route
Left verbatim so the original intent stays visible; everything below elaborates it.
Summary
Today the planned-workout UI only lights up for a signed-in intervals.icu athlete: LoadTodaysWorkoutButton is rendered under the showAuthFeatures gate in App.tsx, and intervalsIcuWorkoutService requires an access token. A guest visitor on the demo route (Willowbrook River, bundled from src/data/seedRouteCoordinates.ts) sees no workout overlay at all, so the WorkoutBlockPanel interval-strip visual (one of the stronger "this is a coached rower" affordances) is invisible on the signed-out landing experience.
This issue adds a placeholder interval workout that is pre-wired to the demo route and renders its block image to logged-out visitors, with no intervals.icu round-trip. Specifically:
- A canned structured workout whose total prescribed distance equals the demo route's total distance (so the final cooldown block visually finishes at the finish line of the demo route).
- A loader seam that bypasses the auth-gated
intervalsIcuWorkoutServicefor guests on the demo route only, soWorkoutBlockPanellights up without touching Dan's real workout state. - A Playwright smoke that drives the full "signed-out visitor sees the workout image" path.
Scope stays "placeholder": no new workout types, no editor, no persistence to the server, no behaviour change for signed-in rowers.
Branch target (important)
Main is in the middle of the #542 Svelte/WebGPU rebuild and PR #587 removes src/App.tsx, src/components/, src/hooks/, and the React workout components wholesale on next/svelte-webgpu. The surviving workout plumbing on that branch lives under:
src/lib/ui/WorkoutBlockPanel.svelte(+.css) — the Svelte port of the block strip.src/lib/ui/LoadTodaysWorkoutButton.svelte— the auth-gated loader button.src/lib/ui/currentWorkout.svelte.ts— the Svelte-rune version ofuseCurrentWorkout.src/lib/services/intervalsIcuWorkoutService.ts,src/lib/services/workoutService.ts,src/lib/services/localStorageWorkoutStore.ts.src/lib/utils/workoutPlan.ts.
The React equivalents (src/components/WorkoutBlockPanel.tsx, src/components/LoadTodaysWorkoutButton.tsx, src/hooks/useCurrentWorkout.ts, src/services/intervalsIcuWorkoutService.ts) only exist on main until #587 lands. D1 below asks where this issue should ship; the FR/AC are written against next/svelte-webgpu's module layout and renamed where a decision could flip them to main.
Design decisions to resolve before implementation
Each is grounded in a real file/function in the repo; Dan picks tersely (d1 a, d2 c, …) before FR/AC are considered locked.
-
D1 — Which branch does this ship on?
- a.
next/svelte-webgpuonly. Lands after #542 AC1: remove the React tree from next/svelte-webgpu #587 (AC1 React removal) and uses the Svelte modules above. Default recommendation — React workout code is on the chopping block this week. - b.
main(React) only, as a short-lived band-aid for the demo CTA until the Svelte cutover. - c. Both branches, cherry-picked forward through the
#542main →next/svelte-webgpuarrow. Doubles the effort for a visual that only matters on whichever branch is deployed.
- a.
-
D2 — Where does the placeholder workout definition live?
- a. New
src/lib/data/demoWorkout.tsnext toseedRouteCoordinates.ts, exporting a staticdemoIntervalWorkout: StructuredWorkoutobject (same shape asIntervalsIcuWorkoutService.toStructuredWorkoutreturns). Default recommendation — mirrors how the demo route is bundled. - b. A deterministic generator function
buildDemoWorkout(totalMeters: number)that composes warmup + intervals + cooldown to match the demo route'srouteTotalDistanceMetersat build time. More flexible if the demo route length drifts, but one more code path to test. - c. A JSON blob under
public/fetched at runtime. Rejected inline here — introduces a fetch for a value we already know at build time and complicates the no-network guest path.
- a. New
-
D3 — How is the demo workout surfaced to the guest?
- a. Auto-loaded into
currentWorkout.svelte.tswhen a guest session starts on the demo route;WorkoutBlockPanelrenders exactly as it does for a signed-in rower who has pressed "Load today's workout". Default recommendation — zero new UI, maximum visual coverage. - b. A dedicated "Preview a sample workout" button on the signed-out landing surface (near the demo CTA at
App.tsxL555–565 / its Svelte equivalent) that users must press. - c. Both — preview visible by default, button to hide/reload it.
- a. Auto-loaded into
-
D4 — What is the "fudge" seam for the test and for guests?
- a. A new
DemoWorkoutPorton the ports layer (src/lib/ports/) with agetDemoWorkout(): StructuredWorkoutmethod, wired throughuseServices()so unit tests can swap it with a stub. Default recommendation — follows the existingPick<Service, …>ports convention and avoids piggy-backing onIntervalsIcuWorkoutService. - b. Extend
IntervalsIcuWorkoutServicewith agetDemoWorkout()method that bypasses the HTTP client whenauthService.isAuthenticated()is false. - c. Reuse the existing
window.__workoutServicetest seam (declared inworkoutService.ts) by shipping a hidden "inject demo workout" entrypoint on it in all environments, not just under__PLAYWRIGHT_TESTING. Rejected inline — leaks a test seam to production.
- a. A new
-
D5 — Interval structure of the placeholder.
- a.
warmup 500 m @ UT2 → 4 × (750 m @ T → 250 m @ UT2) → cooldown 500 m @ UT1. Total = 500 + 4×1000 + 500 = 5 000 m, matching Willowbrook's ~5 km course (routeService.tsL24 comment). - b.
warmup 1 000 m → 5 × (500 m hard / 500 m easy) → cooldown 500 m. Total = 1 000 + 5 000 + 500 = 6 500 m — would require the demo route to extend, which it will not. - c. Pyramid
warmup → 250 / 500 / 750 / 500 / 250 m hard with equal rests → cooldown, auto-scaled by D2b. Nicer-looking strip, more moving parts. - Default recommendation: a. Round numbers, lands flush with the finish line, matches shapes
WorkoutBlockPanel.tsxalready renders well.
- a.
-
D6 — Target units in the placeholder.
- a. Pace targets only (
/500 msplits), e.g. T blocks at2:05/500 m. Universally meaningful; no coupling to an athlete's power zones. - b. Power targets only (watts), e.g. T blocks at
220 W. Looks more "coached" but is meaningless to a guest without context. - c. Both pace and power on each block.
- Default recommendation: a.
- a. Pace targets only (
-
D7 — Does the placeholder show for signed-in rowers who have not loaded a workout today?
- a. No — placeholder only replaces the empty
WorkoutBlockPanelfor guests on the demo route. Default recommendation — avoids any chance of confusing a logged-in athlete into thinking the sample is their prescription. - b. Yes — any session (demo or not, logged-in or not) with no current workout gets the placeholder.
- a. No — placeholder only replaces the empty
-
D8 — Visual gate between placeholder and "real" workouts.
- a. Prefix the block-panel title with "Sample workout" and lower-opacity the strip's current-block card.
- b. Add a small corner badge "Demo" on the strip SVG.
- c. Both.
- Default recommendation: a.
-
D9 — Clean-up when a guest signs in.
- a. On successful sign-in while the placeholder is active, clear
currentWorkoutso the user's own "Load today's workout" button starts from an empty panel. Default recommendation. - b. Keep the placeholder loaded until the user presses "Load today's workout" themselves.
- a. On successful sign-in while the placeholder is active, clear
Functional requirements
FR1 — Placeholder workout definition
The codebase SHALL export a single StructuredWorkout (per D2) whose total prescribed distance equals the demo route's total distance measured by routeTotalDistanceMeters(willowbrookRiverCoordinates) to within ±5 m. The workout SHALL conform to the StructuredWorkout / WorkoutSegment shape in src/types/index.ts (or its src/lib/types/ equivalent on next/svelte-webgpu) so it is indistinguishable, downstream, from a workout produced by IntervalsIcuWorkoutService.toStructuredWorkout.
FR2 — Demo-workout port
A new DemoWorkoutPort SHALL be added to the ports layer (per D4a) with method getDemoWorkout(): StructuredWorkout. A concrete DemoWorkoutService under src/lib/services/ SHALL return the FR1 workout. The port SHALL be wired through useServices() so unit tests and the Playwright smoke can swap it for a stub without touching the real intervals.icu service.
FR3 — Auto-load for guests on the demo route
When all of (a) the active route id equals DEMO_ROUTE_ID (App.tsx L98 or its Svelte equivalent), (b) authService.isAuthenticated() is false (per isGuestSession at App.tsx L138), and (c) currentWorkout is currently empty are true, the client SHALL call DemoWorkoutPort.getDemoWorkout() and seed currentWorkout with the result exactly as if LoadTodaysWorkoutButton.onLoad had fired. The seed SHALL happen before the first render of the row surface so WorkoutBlockPanel paints with the placeholder on the first frame.
FR4 — Visual rendering of the block image
With the placeholder seeded, WorkoutBlockPanel.svelte (or .tsx under D1b) SHALL render the strip, the current-block card, and the prescribed-vs-actual tiles exactly as it does for a loaded intervals.icu workout, with the D8 "sample workout" visual gate applied. No new render component SHALL be added.
FR5 — Guest-only, demo-only scope
The placeholder SHALL NOT seed currentWorkout:
- when the active route id is anything other than
DEMO_ROUTE_ID; - when
authService.isAuthenticated()is true (regardless of whether the user has loaded a workout); - when
__PLAYWRIGHT_TESTINGis true and a test has already injected a workout throughwindow.__workoutService(so the existingload-todays-workout.spec.tsis undisturbed).
FR6 — Clean-up on sign-in (per D9)
If a guest signs in while the placeholder is active, the client SHALL clear currentWorkout within one render frame of the auth state transitioning to signed-in, leaving the panel empty until the real "Load today's workout" button is pressed.
FR7 — No save-to-server, no telemetry
A session rowed by a guest with the placeholder active SHALL NOT POST the placeholder (or any derived progress object) to intervals.icu, Postgres, or any other remote. The guest-save gate in localStorageWorkoutStore.ts L57–59 (isDemo || isGuest) MUST continue to prevent server writes.
FR8 — Test seam parity with real loader
The Playwright signed-out suite SHALL be able to force the placeholder off (to assert the empty-panel state) and on (to assert the block-image state) by swapping the DemoWorkoutPort through the existing DI, not by patching globals. The injection SHALL work from playwright/tests/signed-out-test-drive.spec.ts without duplicating the mock-BT fixture.
Non-functional requirements
- NFR1 — Zero network. The guest render path for the placeholder MUST issue no HTTP request to intervals.icu or any other origin. Network inspection in the Playwright smoke SHALL assert zero outbound requests to
intervals.icu. - NFR2 — No bundle-size regression. The placeholder definition (D2a) and port glue SHALL add ≤ 2 KB gzipped to the production bundle at branch point, measured against the branch tip the PR is based on.
- NFR3 — Coverage ratchet. New files land with unit tests; thresholds in
vitest.config.tsare raised, not lowered, per the project's coverage ratchet convention (CLAUDE.md). - NFR4 — Type safety. The placeholder definition SHALL type-check under
tsconfig.app.jsonstrict mode without a cast; the port MUST follow thePick<Service, …>structural-type convention insrc/lib/ports/index.ts. - NFR5 — Visual-baseline stability. The block-strip's SVG MUST render deterministically from a seeded input so the visual-baseline job (CI SwiftShader) does not flake. If the strip currently uses animation, the baseline frame SHALL freeze it per the existing
__ROWER3D_FREEZEconvention. - NFR6 — No behaviour change for signed-in rowers. No test under
src/__tests__/useCurrentWorkout.test.*,src/__tests__/loadTodaysWorkoutButton.svelte.test.ts, orsrc/__tests__/workoutService.*.test.tsis modified except to add placeholder cases alongside.
Acceptance criteria
Each item is verifiable; each maps to one or more FR/NFR.
- AC1 (FR1, D5a) — A unit test asserts the placeholder workout's total prescribed distance equals
routeTotalDistanceMeters(willowbrookRiverCoordinates)within ±5 m and that its segment list matches the D5a shape (warmup 500 → 4 × (750 T, 250 UT2) → cooldown 500). - AC2 (FR2, FR8, NFR4) —
src/lib/ports/index.tsexportsDemoWorkoutPortas aPickof the concrete service; a unit test satisfies the port with a plain-object stub (noas unknown ascast). - AC3 (FR3) — With
__PLAYWRIGHT_TESTINGoff and no auth token, loading the app on the demo route seedscurrentWorkoutwith the placeholder before the firstWorkoutBlockPanelpaint. Asserted by a Vitest render test on the Svelte row surface (or its React equivalent under D1b). - AC4 (FR4, D8a) — A Vitest snapshot (or structural assertion) of
WorkoutBlockPanelunder the placeholder shows the title prefixed "Sample workout" and the strip rendered with the correct number of blocks and relative widths. - AC5 (FR5) — Unit tests cover all three negative cases: (i) non-demo route id with no auth → panel stays empty; (ii) authenticated user on the demo route → panel stays empty; (iii) under
__PLAYWRIGHT_TESTINGwith a workout already injected viawindow.__workoutService→ the injected workout wins, not the placeholder. - AC6 (FR6) — A render test transitions
authServicefrom signed-out to signed-in while the placeholder is active and assertscurrentWorkoutis empty within one render frame. - AC7 (FR7, NFR1) — The Playwright signed-out smoke (
playwright/tests/signed-out-test-drive.spec.tsextended, or a new sibling) loads/, asserts the block-strip is visible with the placeholder, rows 500 m, and asserts: zero network calls tointervals.icu, no POST to any backend, andlocalStorageWorkoutStoredoes not persist a workout record. - AC8 (FR4, NFR5) — Visual-baseline job (CI SwiftShader) captures a stable frame of the signed-out landing with the block-image placeholder visible; recorded with the
visual-baselinelabel per the project's baseline flow (CLAUDE.md). - AC9 (NFR2) — Bundle-size check asserts the placeholder-enabled build is ≤ 2 KB gzipped larger than the branch-point baseline.
- AC10 (NFR3) —
vitest.config.tscoverage thresholds for the new files and the touched entrypoints are raised, not lowered, after the change. - AC11 (NFR6) — The pre-existing
load-todays-workout.spec.tspasses without edits other than adding a precondition that disables the placeholder (via the FR8 seam) when the test wants to exercise the empty-panel state.
Test scenarios
Covering the ACs end-to-end:
- T1 — Unit, placeholder shape (AC1, AC4).
src/__tests__/demoWorkout.test.ts: asserts total distance, segment count, intensity sequence, and theintensityLabel/segmentTypeLabeloutputs throughsrc/lib/utils/workoutPlan.ts. - T2 — Unit, port (AC2).
src/__tests__/demoWorkoutPort.test.ts: structural satisfaction ofDemoWorkoutPortwith a plain-object stub;useServices()returns the stub in a test render. - T3 — Render, auto-seed (AC3, AC4, AC5, AC6). Vitest + Testing Library render of the row surface:
- guest + demo route id → panel shows placeholder with "Sample workout" prefix;
- guest + non-demo route id → empty panel;
- signed-in + demo route id → empty panel;
- transition signed-out → signed-in with placeholder active → panel clears.
- T4 — Playwright, signed-out smoke (AC7, AC8). Extend
signed-out-test-drive.spec.ts(or addsigned-out-workout-image.spec.ts):- navigate to
/, assertWorkoutBlockPanelis visible with the placeholder's first block as the current-block card; - row 500 m via the existing mock-BT fixture, assert progress ticks through the warmup block;
- assert zero outbound requests to
intervals.icuusingpage.route; - freeze the frame (
__ROWER3D_FREEZE) and capture the visual-baseline frame.
- navigate to
- T5 — Playwright, authed suite unchanged (AC11).
load-todays-workout.spec.tscontinues to pass; abeforeEachdisables the placeholder via the FR8 port stub so the empty-panel assertions are untouched. - T6 — Bundle size (AC9). Add a CI step (or extend the existing size-limit workflow if present) that compares the gzipped bundle to the branch tip; fails if the delta exceeds 2 KB.
Suggested phasing (non-binding)
- Phase 1 — Data + port. D2 definition,
DemoWorkoutPort, unit tests. Satisfies AC1, AC2, AC10. No user-visible change. - Phase 2 — Auto-seed + UI gate. FR3, FR5, FR6, D8 visual gate. Satisfies AC3, AC4, AC5, AC6, AC11.
- Phase 3 — Smoke + visual baseline. Playwright signed-out smoke,
visual-baselinelabel re-record. Satisfies AC7, AC8. - Phase 4 — Size guard. Bundle-size check. Satisfies AC9.
Out of scope (explicit)
- A workout editor or any ability for guests to pick / change the placeholder.
- Multiple placeholder variants or route-length-adaptive generation (D2b, D5c are deferred).
- Persistence of the guest's placeholder session to any remote.
- Any change to
IntervalsIcuWorkoutService's HTTP path, auth retry, or 401 handling. - Any change to the demo route's coordinates or length.
- Showing the placeholder in the Routes map-primary screen (update route selection screen #505) or on the route-search cards — this issue is scoped to the row surface only.
Overlap / coordination
- Rebuild virtualrow on Svelte + a Biketerra-model WebGPU renderer, on its own branch (epic) #542 (Svelte/WebGPU rebuild) — direct overlap. PR #542 AC1: remove the React tree from next/svelte-webgpu #587 (AC1 React removal) deletes
src/App.tsx,src/components/,src/hooks/wholesale. D1a is the default for exactly this reason. If D1b (React only) is picked, the work is band-aid and must land before #542 AC1: remove the React tree from next/svelte-webgpu #587 merges. - Multiplayer: show other rowers on the same route in real time #438 (Multiplayer presence) — no conflict. Different port, different surface; the placeholder renders in the local block panel, not the 3D scene.
- Monthly GitHub Action: run llm-cad against new rownative routes and register generated scenery #449, Update persistence to use remote postgres database. Change the build file to set up the database & include the ability for database deltas & rollbacks #37, Host Postgres on Neon free tier and store connection details as GitHub secrets #441, D4 follow-up: runtime access path from browser to Neon Postgres (proxy or edge service) #451, Move rower appearance preference to Postgres user profile when #37 lands #477, Let a spec ask for the shipping scene (#419) #427, Restore INTERVALS_ICU_TEST_API_KEY and INTERVALS_ICU_TEST_ATHLETE_ID repo secrets so the nightly FIT upload contract check runs again #471 — parked, no conflict. None touch the workout panel or guest row surface.
- update route selection screen #505 (Route map v2) — no conflict. The placeholder is on the row surface, not the routes screen; the Routes drawer and pins are untouched.
Author notes
- Requirements written to be testable and to align with existing project conventions (ports as
Pick, Playwright + Vitest split, visual baselines per CLAUDE.md'svisual-baselineflow, coverage ratchet invitest.config.ts). - Defaults in D1–D9 are recommendations only; push back on any of them before FR/AC are considered locked.
Activity