diff --git a/components/on-air/mindless.retry.test.tsx b/components/on-air/mindless.retry.test.tsx new file mode 100644 index 0000000000..54508061c5 --- /dev/null +++ b/components/on-air/mindless.retry.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ load: vi.fn(), active: vi.fn(), refresh: vi.fn(), engines: [] as { kind: string; props: Record }[] })) +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: m.refresh }) })) +vi.mock('@/app/(main)/on-air/actions', () => ({ loadOnAirSession: m.load })) +vi.mock('@/app/(main)/on-air/timer-session-actions', () => ({ getActiveTimerSession: m.active })) +vi.mock('@/lib/fullscreen', () => ({ requestAppFullscreen: async () => {}, exitAppFullscreen: async () => {} })) +vi.mock('next/dynamic', () => ({ default: (loader: () => unknown) => { + const kind = String(loader).includes('movement-session') ? 'move' : 'still' + return (props: Record) => { + m.engines.push({ kind, props }) + return + } +} })) + +import { MindlessProvider, useMindless, type MindlessOpenOpts } from '@/components/on-air/mindless' +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let host: HTMLDivElement +function Open({ opts }: { opts: MindlessOpenOpts }) { const timer = useMindless(); return } +function result(id = 'run') { return { data: { practices: [{ id, timerKind: id.startsWith('still') ? 'mindless' : 'movement', title: id, movementConfig: { mode: 'walk' } }], defaultPracticeId: id, prefs: {}, practicedToday: false } } } +async function open(opts: MindlessOpenOpts) { await act(async () => root.render()); await act(async () => host.querySelector('button')!.click()) } +async function retry() { const b = [...host.querySelectorAll('button')].find(x => x.textContent === 'Try again')!; expect(b).toBeTruthy(); await act(async () => b.click()) } +beforeEach(() => { window.localStorage.clear(); m.engines.length=0; m.load.mockReset(); m.active.mockResolvedValue({ data: { session: null, expired: false } }); host=document.createElement('div');document.body.append(host);root=createRoot(host) }) +afterEach(async () => { await act(async () => root.unmount());host.remove() }) + +describe('Mindless loading retry intent', () => { + it.each(['returned', 'rejected'])('replays the original explicit resume after %s failure', async kind => { + if (kind === 'returned') m.load.mockResolvedValueOnce({ error: 'Unavailable' }); else m.load.mockRejectedValueOnce(new Error('Network')) + m.load.mockResolvedValue(result()) + await open({ practiceId: 'run', mode: 'walk', autoStart: true, resumeFromSec: 90, secondsTarget: 300, minutesOverride: 12, warmupMessage: 'This is the requested leg' }) + expect(host.textContent).toContain('That didn’t open') + await retry() + expect(m.load.mock.calls.map(c => c[0])).toEqual(['run', 'run']) + expect(m.engines.at(-1)?.kind).toBe('move') + expect(m.engines.at(-1)?.props).toMatchObject({ defaultMode: 'walk', resumeFromSec: 90, secondsTarget: 300, minutesOverride: 12, autoStart: true, warmupMessageOverride: 'This is the requested leg' }) + }) + it('retries the failed second queue leg and advances to the third once', async () => { + let failed = false + m.load.mockImplementation(async (id: string) => { if (id === 'run2' && !failed) { failed=true; return { error: 'Unavailable' } } return result(id) }) + await open({ queue: ['still1', 'run2', 'still3'], autoStart: true }) + await act(async () => host.querySelector('[data-testid="engine"]')!.click()) + await retry() + expect(m.engines.at(-1)?.props.queuePosition).toEqual({ index: 1, total: 3 }) + await act(async () => host.querySelector('[data-testid="engine"]')!.click()) + expect(m.load.mock.calls.map(c => c[0])).toEqual(['still1', 'run2', 'run2', 'still3']) + expect(m.engines.at(-1)?.props.queuePosition).toEqual({ index: 2, total: 3 }) + }) + it('keeps the recovered movement engine on retry', async () => { + window.localStorage.setItem('fq_live_session_movement', JSON.stringify({ kind: 'movement', startedAt: Date.now()-60000, pausedAt: null, practiceId: 'run', resumeFromSec: 0, secondsTarget: 300, savedAt: Date.now(), setup: {} })) + m.load.mockRejectedValueOnce(new Error('Network')).mockResolvedValue(result()) + await act(async () => root.render(Page)) + await retry() + expect(m.engines.at(-1)?.kind).toBe('move') + }) +}) diff --git a/components/on-air/mindless.tsx b/components/on-air/mindless.tsx index 0497ce7cc3..91949d7ecb 100644 --- a/components/on-air/mindless.tsx +++ b/components/on-air/mindless.tsx @@ -117,25 +117,27 @@ interface ResumeInfo { secondsTarget: number } +type LoadingState = { + phase: 'loading' + setup?: boolean + minutesOverride?: number + practiceId?: string + movementMode?: MovementMode + resume?: ResumeInfo + /** A crash-recovery open forces the mode so the engine that owns the saved record mounts. */ + forceMode?: TimerMode + /** A practice-select launch auto-starts the countdown (skips setup). */ + autoStart?: boolean + /** A per-step warm-up message override (P5), carried to the session's pre-roll. */ + warmupMessage?: string + /** A sequenced run's ordered practice ids + this leg's index (P6). */ + queue?: string[] + queueIndex?: number +} + type OverlayState = | { phase: 'closed' } - | { - phase: 'loading' - setup?: boolean - minutesOverride?: number - practiceId?: string - movementMode?: MovementMode - resume?: ResumeInfo - /** A crash-recovery open forces the mode so the engine that owns the saved record mounts. */ - forceMode?: TimerMode - /** A practice-select launch auto-starts the countdown (skips setup). */ - autoStart?: boolean - /** A per-step warm-up message override (P5), carried to the session's pre-roll. */ - warmupMessage?: string - /** A sequenced run's ordered practice ids + this leg's index (P6). */ - queue?: string[] - queueIndex?: number - } + | LoadingState | { phase: 'ready' setup?: boolean @@ -158,7 +160,7 @@ type OverlayState = queue?: string[] queueIndex?: number } - | { phase: 'error' } + | { phase: 'error'; request: LoadingState } /** The mode to open in, routed from the opened practice's timer_kind. A 'movement' practice * opens Get Moving; 'mindless' / 'none' open Be Still. A crash-recovery open forces the mode. @@ -313,12 +315,12 @@ export function MindlessProvider({ children }: { children: React.ReactNode }) { try { result = await loadOnAirSession(requestedPracticeId) } catch { - if (live) setState({ phase: 'error' }) + if (live) setState({ phase: 'error', request: state }) return } if (!live) return if (isError(result)) { - setState({ phase: 'error' }) + setState({ phase: 'error', request: state }) return } const mode = routeInitialMode(result.data, requestedPracticeId, forcedMovementMode, forceMode) @@ -434,7 +436,11 @@ export function MindlessProvider({ children }: { children: React.ReactNode }) {

That didn’t open. Give it another go.

diff --git a/components/on-air/session.tsx b/components/on-air/session.tsx index 9a6d90b015..45d35cc0f4 100644 --- a/components/on-air/session.tsx +++ b/components/on-air/session.tsx @@ -489,6 +489,7 @@ export function OnAirSession({ startedIso: string | null practiceIdOverride?: string modeOverride?: SessionMode + attended: boolean } | null>(null) const audio = useRef(null) const ambient = useRef(null) @@ -1179,7 +1180,7 @@ export function OnAirSession({ ) { // Remember this attempt's exact args so an error-screen retry replays the SAME proof-valid // claim (real seconds + started_at), never a recomputed null-anchored one. - lastFinishArgs.current = { seconds, startedIso, practiceIdOverride, modeOverride } + lastFinishArgs.current = { seconds, startedIso, practiceIdOverride, modeOverride, attended } // The sit is ending: drop the crash-recovery cache + the server active session (completeSession // also clears the row server-side as the authoritative end). clearLiveSession('mindless') @@ -1407,7 +1408,7 @@ export function OnAirSession({ // Replay the failed attempt's exact args (real seconds + started_at) so the timer-proof // passes; fall back to a live recompute only if none was captured. const a = lastFinishArgs.current - if (a) void finishWith(a.seconds, a.startedIso, a.practiceIdOverride, a.modeOverride) + if (a) void finishWith(a.seconds, a.startedIso, a.practiceIdOverride, a.modeOverride, a.attended) else void finishWith(Math.round((Date.now() - startedAt) / 1000), new Date(startedAt).toISOString()) }} > diff --git a/docs/ledger/rows/TIMER-RETRY-INTENT.json b/docs/ledger/rows/TIMER-RETRY-INTENT.json new file mode 100644 index 0000000000..a75bb5e16d --- /dev/null +++ b/docs/ledger/rows/TIMER-RETRY-INTENT.json @@ -0,0 +1,19 @@ +{ + "id": "TIMER-RETRY-INTENT", + "title": "Timer retries preserve the original practice, queue, resume and attendance intent", + "lane": "live", + "priority": "P2", + "size": "S", + "status": "open", + "source": { + "file": "components/on-air/mindless.tsx", + "ref": "Independent UX audit UXJ-04 and integration audit INT-08, rechecked 2026-11-04." + }, + "detail": "Mindless load failures previously discarded the requested practice, movement mode, queue leg and resume context; Try again called the generic open defaults. The local source candidate saves and replays its normalized loading request, retaining close/stale-load controls. Be Still history retry now retains the original attended flag alongside seconds, start, practice and mode; attended=false retries must not become attended=true. Movement already retained its options and is unchanged. Acceptance: returned/rejected data-load failures retry the original practice/resume; failure on the second queue leg retries that leg and advances once; persisted movement crash recovery remains intact; partial Be Still finish retries preserve attended=false; no reward, duration, WAM or server-action semantics are changed. Daniel specifically approved the exact four-test Mindless regression review patch, now applied. Remains open pending integrated-head required CI/preview and appropriate browser/production verification.", + "verify": { + "kind": "manual", + "checked": "2026-11-04", + "evidence": "On isolated base2692ad9, 79 tests in nine focused suites passed, including all four specifically approved provider retry regressions. Focused lint and whitespace checks passed. Temporary bounded proof also executed the actual Be Still saved-argument/replay source seams and preserved attended=false. Pristine-base negative control reproduced four defects and retained one positive control. No real session completion or production mutation was performed. Full TypeScript had no verdict and was stopped; not claimed green." + }, + "wave": "W7" +}