From 4496636541084997abf05946fb90a94c56787058 Mon Sep 17 00:00:00 2001 From: "Titux Metal (MBP)" Date: Fri, 26 Jun 2026 05:19:11 +0200 Subject: [PATCH 1/2] perf(coach): prewarm milestone cache during idle to unblock first lesson open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first milestone access runs solveCube on the fixed scramble (~420ms) on the main thread, freezing the UI on the first lesson's first demo. Export warmMilestones and call it from the Coach route via requestIdleCallback (with a setTimeout fallback), so the solve happens while the learner reads the first screen and the demo opens against a warm cache. Keeps the real-solve doctrine — no hand-built hardcoded state — so the milestones stay engine-derived. Refs #17 --- .../features/coach/data/illustrative.spec.ts | 11 +++++++ .../src/features/coach/data/illustrative.ts | 8 +++++ apps/web/src/features/coach/index.ts | 1 + apps/web/src/pages/Coach.tsx | 30 +++++++++++++++---- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/coach/data/illustrative.spec.ts b/apps/web/src/features/coach/data/illustrative.spec.ts index fc98b63..3594345 100644 --- a/apps/web/src/features/coach/data/illustrative.spec.ts +++ b/apps/web/src/features/coach/data/illustrative.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'bun:test' import { secondLayerState, + warmMilestones, whiteCornersState, whiteCrossOnlyState, yellowCrossState, @@ -87,3 +88,13 @@ describe('production milestones are genuine, reachable partial states (SUG-1)', expect(D_CORNERS.every(p => cornerHome(s, p))).toBe(true) }) }) + +describe('warmMilestones (#17 idle prewarm)', () => { + it('primes the milestone cache without throwing, idempotently', () => { + expect(() => warmMilestones()).not.toThrow() + // a second call is a cache hit — still safe, no recompute side effects + expect(() => warmMilestones()).not.toThrow() + // states are genuine and available once warmed + expect(twoLayersComplete(secondLayerState())).toBe(true) + }) +}) diff --git a/apps/web/src/features/coach/data/illustrative.ts b/apps/web/src/features/coach/data/illustrative.ts index d0870e5..c06a6ab 100644 --- a/apps/web/src/features/coach/data/illustrative.ts +++ b/apps/web/src/features/coach/data/illustrative.ts @@ -124,6 +124,14 @@ const computeMilestones = (): Milestones => { const milestones = (): Milestones => (cache ??= compute()) +// Eagerly fill the memoised cache. The first milestone access runs solveCube on the +// fixed scramble (~420ms on the main thread); calling this from the Coach route during +// idle time means the first lesson's first demo hits a warm cache instead of paying +// that cost mid-click (#17). Idempotent — a second call is a cheap cache hit. +export const warmMilestones = (): void => { + milestones() +} + // CubeState milestones — the store builds a case demo as applyMoves(milestone, // invertMoves(alg)), so it needs the state, not just the stickers. export const whiteCrossOnlyState = (): CubeState => milestones().whiteCrossOnly diff --git a/apps/web/src/features/coach/index.ts b/apps/web/src/features/coach/index.ts index c7443e0..8feeb5e 100644 --- a/apps/web/src/features/coach/index.ts +++ b/apps/web/src/features/coach/index.ts @@ -1,4 +1,5 @@ export { CoachErrorBoundary, LessonBrowser, LessonPlayer } from './components' +export { warmMilestones } from './data/illustrative' export { LESSONS, getLesson } from './data/lessons' export type { Lesson, LessonStep } from './data/types' export { stepAlgorithmId } from './data/types' diff --git a/apps/web/src/pages/Coach.tsx b/apps/web/src/pages/Coach.tsx index 4d90c7e..93c0b90 100644 --- a/apps/web/src/pages/Coach.tsx +++ b/apps/web/src/pages/Coach.tsx @@ -1,7 +1,25 @@ -import { CoachErrorBoundary, LessonBrowser, LessonPlayer } from '~/features/coach' +import { useEffect } from 'react' -export const Coach = ({ lessonId }: { lessonId?: string }) => ( - - {lessonId ? : } - -) +import { CoachErrorBoundary, LessonBrowser, LessonPlayer, warmMilestones } from '~/features/coach' + +export const Coach = ({ lessonId }: { lessonId?: string }) => { + // Prewarm the solver-derived milestone cache during idle time: the first milestone + // access runs solveCube (~420ms) on the main thread, so doing it here — while the + // learner reads the first screen — means the first demo opens against a warm cache + // instead of freezing on the click (#17). Falls back to a short timer where + // requestIdleCallback is unavailable; the cleanup cancels it on unmount. + useEffect(() => { + if (typeof window.requestIdleCallback === 'function') { + const handle = window.requestIdleCallback(() => warmMilestones()) + return () => window.cancelIdleCallback(handle) + } + const timer = setTimeout(warmMilestones, 200) + return () => clearTimeout(timer) + }, []) + + return ( + + {lessonId ? : } + + ) +} From aaf5ff20d6c5db869b0a206ea5099076d776eccc Mon Sep 17 00:00:00 2001 From: "Titux Metal (MBP)" Date: Fri, 26 Jun 2026 05:19:36 +0200 Subject: [PATCH 2/2] test(coach): cover the LessonPlayer last-step navigation fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last step of a chapter renders either a 'Chapitre suivant' link to the next chapter in journey order or a 'Retour au Coach' link on the final chapter — neither branch was exercised. Add a test for each: an intermediate chapter links to the next lesson id, and the final chapter links back to /coach. Refs #18 --- .../coach/components/LessonPlayer.spec.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/web/src/features/coach/components/LessonPlayer.spec.tsx b/apps/web/src/features/coach/components/LessonPlayer.spec.tsx index 07e0f0c..21c7a04 100644 --- a/apps/web/src/features/coach/components/LessonPlayer.spec.tsx +++ b/apps/web/src/features/coach/components/LessonPlayer.spec.tsx @@ -88,4 +88,22 @@ describe('LessonPlayer', () => { render() expect(screen.getByText('Leçon introuvable')).toBeDefined() }) + + it('should link the last step of an intermediate chapter to the next chapter in journey order', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText(/^Étape 5 :/)) + + const next = screen.getByRole('link', { name: 'Chapitre suivant' }) + expect(next.getAttribute('href')).toBe('/coach/white-corners') + }) + + it('should link the last step of the final chapter back to the Coach index', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText(/^Étape 3 :/)) + + const back = screen.getByRole('link', { name: 'Retour au Coach' }) + expect(back.getAttribute('href')).toBe('/coach') + }) })