Skip to content

Add workout image to website - placeholder #472

Open

Description

@MaximumTrainer

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:

  1. 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).
  2. A loader seam that bypasses the auth-gated intervalsIcuWorkoutService for guests on the demo route only, so WorkoutBlockPanel lights up without touching Dan's real workout state.
  3. 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 of useCurrentWorkout.
  • 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-webgpu only. 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 #542 main → next/svelte-webgpu arrow. Doubles the effort for a visual that only matters on whichever branch is deployed.
  • D2 — Where does the placeholder workout definition live?

    • a. New src/lib/data/demoWorkout.ts next to seedRouteCoordinates.ts, exporting a static demoIntervalWorkout: StructuredWorkout object (same shape as IntervalsIcuWorkoutService.toStructuredWorkout returns). 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's routeTotalDistanceMeters at 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.
  • D3 — How is the demo workout surfaced to the guest?

    • a. Auto-loaded into currentWorkout.svelte.ts when a guest session starts on the demo route; WorkoutBlockPanel renders 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.tsx L555–565 / its Svelte equivalent) that users must press.
    • c. Both — preview visible by default, button to hide/reload it.
  • D4 — What is the "fudge" seam for the test and for guests?

    • a. A new DemoWorkoutPort on the ports layer (src/lib/ports/) with a getDemoWorkout(): StructuredWorkout method, wired through useServices() so unit tests can swap it with a stub. Default recommendation — follows the existing Pick<Service, …> ports convention and avoids piggy-backing on IntervalsIcuWorkoutService.
    • b. Extend IntervalsIcuWorkoutService with a getDemoWorkout() method that bypasses the HTTP client when authService.isAuthenticated() is false.
    • c. Reuse the existing window.__workoutService test seam (declared in workoutService.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.
  • 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.ts L24 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.tsx already renders well.
  • D6 — Target units in the placeholder.

    • a. Pace targets only (/500 m splits), e.g. T blocks at 2: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.
  • D7 — Does the placeholder show for signed-in rowers who have not loaded a workout today?

    • a. No — placeholder only replaces the empty WorkoutBlockPanel for 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.
  • 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 currentWorkout so 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.

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_TESTING is true and a test has already injected a workout through window.__workoutService (so the existing load-todays-workout.spec.ts is 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.ts are raised, not lowered, per the project's coverage ratchet convention (CLAUDE.md).
  • NFR4 — Type safety. The placeholder definition SHALL type-check under tsconfig.app.json strict mode without a cast; the port MUST follow the Pick<Service, …> structural-type convention in src/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_FREEZE convention.
  • NFR6 — No behaviour change for signed-in rowers. No test under src/__tests__/useCurrentWorkout.test.*, src/__tests__/loadTodaysWorkoutButton.svelte.test.ts, or src/__tests__/workoutService.*.test.ts is modified except to add placeholder cases alongside.

Acceptance criteria

Each item is verifiable; each maps to one or more FR/NFR.

  1. 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).
  2. AC2 (FR2, FR8, NFR4) — src/lib/ports/index.ts exports DemoWorkoutPort as a Pick of the concrete service; a unit test satisfies the port with a plain-object stub (no as unknown as cast).
  3. AC3 (FR3) — With __PLAYWRIGHT_TESTING off and no auth token, loading the app on the demo route seeds currentWorkout with the placeholder before the first WorkoutBlockPanel paint. Asserted by a Vitest render test on the Svelte row surface (or its React equivalent under D1b).
  4. AC4 (FR4, D8a) — A Vitest snapshot (or structural assertion) of WorkoutBlockPanel under the placeholder shows the title prefixed "Sample workout" and the strip rendered with the correct number of blocks and relative widths.
  5. 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_TESTING with a workout already injected via window.__workoutService → the injected workout wins, not the placeholder.
  6. AC6 (FR6) — A render test transitions authService from signed-out to signed-in while the placeholder is active and asserts currentWorkout is empty within one render frame.
  7. AC7 (FR7, NFR1) — The Playwright signed-out smoke (playwright/tests/signed-out-test-drive.spec.ts extended, or a new sibling) loads /, asserts the block-strip is visible with the placeholder, rows 500 m, and asserts: zero network calls to intervals.icu, no POST to any backend, and localStorageWorkoutStore does not persist a workout record.
  8. 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-baseline label per the project's baseline flow (CLAUDE.md).
  9. AC9 (NFR2) — Bundle-size check asserts the placeholder-enabled build is ≤ 2 KB gzipped larger than the branch-point baseline.
  10. AC10 (NFR3) — vitest.config.ts coverage thresholds for the new files and the touched entrypoints are raised, not lowered, after the change.
  11. AC11 (NFR6) — The pre-existing load-todays-workout.spec.ts passes 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 the intensityLabel / segmentTypeLabel outputs through src/lib/utils/workoutPlan.ts.
  • T2 — Unit, port (AC2). src/__tests__/demoWorkoutPort.test.ts: structural satisfaction of DemoWorkoutPort with 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 add signed-out-workout-image.spec.ts):
    • navigate to /, assert WorkoutBlockPanel is 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.icu using page.route;
    • freeze the frame (__ROWER3D_FREEZE) and capture the visual-baseline frame.
  • T5 — Playwright, authed suite unchanged (AC11). load-todays-workout.spec.ts continues to pass; a beforeEach disables 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-baseline label 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

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's visual-baseline flow, coverage ratchet in vitest.config.ts).
  • Defaults in D1–D9 are recommendations only; push back on any of them before FR/AC are considered locked.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions