Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions components/on-air/mindless.retry.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }[] }))
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<string, unknown>) => {
m.engines.push({ kind, props })
return <button data-testid="engine" onClick={() => { (props.onCompleted as () => void)(); (props.onExit as () => void)() }}>Finish leg</button>
}
} }))

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 <button onClick={() => timer.open(opts)}>Open selected</button> }
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(<MindlessProvider><Open opts={opts} /></MindlessProvider>)); 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<HTMLButtonElement>('[data-testid="engine"]')!.click())
await retry()
expect(m.engines.at(-1)?.props.queuePosition).toEqual({ index: 1, total: 3 })
await act(async () => host.querySelector<HTMLButtonElement>('[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(<MindlessProvider><span>Page</span></MindlessProvider>))
await retry()
expect(m.engines.at(-1)?.kind).toBe('move')
})
})
48 changes: 27 additions & 21 deletions components/on-air/mindless.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -434,7 +436,11 @@ export function MindlessProvider({ children }: { children: React.ReactNode }) {
<p className="text-body-sm font-medium text-text">That didn&rsquo;t open. Give it another go.</p>
<Button
type="button"
onClick={() => open()}
onClick={() => {
void requestAppFullscreen()
// Retry this leg, including its partial/queue context, rather than opening a new generic sit.
setState((current) => current.phase === 'error' ? current.request : current)
}}
>
Try again
</Button>
Expand Down
5 changes: 3 additions & 2 deletions components/on-air/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ export function OnAirSession({
startedIso: string | null
practiceIdOverride?: string
modeOverride?: SessionMode
attended: boolean
} | null>(null)
const audio = useRef<AudioContext | null>(null)
const ambient = useRef<AmbientHandle | null>(null)
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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())
}}
>
Expand Down
19 changes: 19 additions & 0 deletions docs/ledger/rows/TIMER-RETRY-INTENT.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading