diff --git a/app/__tests__/unit/practices/haptics/crisisBlurGate.test.tsx b/app/__tests__/unit/practices/haptics/crisisBlurGate.test.tsx new file mode 100644 index 00000000..086ed08f --- /dev/null +++ b/app/__tests__/unit/practices/haptics/crisisBlurGate.test.tsx @@ -0,0 +1,326 @@ +/** + * DEBUG-587 AC3 — no practice output may reach a crisis surface. + * + * `usePracticeHaptics`' own docstring commits to it: "no haptic may fire on or + * over a crisis surface." Navigation blur is the only signal the hook has that + * the practitioner left, and until DEBUG-587 the hook could not hold that + * signal for longer than one commit. + * + * WHY THESE SPECS EXIST AT ALL. Before this file, the blur path was pinned by + * ZERO tests: `useIsFocusedSafe` reads `NavigationContext`, no existing suite + * supplies one, and without a navigator above it the hook reports focused + * unconditionally. So every prior test of this hook ran with focus welded true + * and could not express blur — which is why the defect below survived a suite + * that otherwise covers this module well. + * + * THE MECHANISM. `activeRef` had TWO writers with DIFFERENT meanings: the + * render body wrote raw `isActive`, and a passive effect wrote + * `isActive && isFocused`. Blur ran the effect once and set the ref false — and + * then every subsequent render restored it to true, because the render-body + * write has no focus term and the effect's deps had not changed. All three + * practice screens re-render about once a second while blurred (the elapsed-time + * tick), so the ref was stale-TRUE for the entire time the practitioner sat on + * the crisis screen, not for the frame or two a naive reading suggests. + * + * These specs assert what a practitioner can actually receive — what reaches + * `expo-haptics` and what reaches the announcement callback — never scheduler + * internals. `nextIndex()` and `elapsedMs()` can both be perfectly correct while + * the practitioner is being buzzed over a 988 screen. + */ + +import React from 'react'; +import { AccessibilityInfo, AppState } from 'react-native'; +import { NavigationContext } from '@react-navigation/native'; +import { render, renderHook, act, screen } from '@testing-library/react-native'; +import * as Haptics from 'expo-haptics'; + +import BreathingCircle from '@/features/practices/shared/components/BreathingCircle'; +import { usePracticeHaptics } from '@/features/practices/shared/haptics/usePracticeHaptics'; +import { __resetHapticEngineForTest } from '@/features/practices/shared/haptics/hapticEngine'; +import { boundariesWithin } from '@/features/practices/shared/haptics/phaseAtElapsed'; +import { DEFAULT_PATTERN } from '@/features/practices/shared/breathingPatterns'; +import { HAPTIC_ANNOUNCEMENT_STAGGER_MS } from '@/features/practices/shared/haptics/constants'; +import type { ScheduledCue } from '@/features/practices/shared/haptics/cueScheduler'; + +jest.mock('@/core/services/featureFlags', () => ({ + isFeatureEnabled: jest.fn(() => true), +})); +jest.mock('@/core/stores/settingsStore', () => ({ + usePracticeSettings: jest.fn(() => ({ practiceHaptics: true })), +})); + +import { isFeatureEnabled } from '@/core/services/featureFlags'; +import { usePracticeSettings } from '@/core/stores/settingsStore'; + +const mockHaptics = Haptics as jest.Mocked; +const mockFlag = isFeatureEnabled as jest.MockedFunction; +const mockSettings = usePracticeSettings as jest.MockedFunction; + +/** + * PracticeTimerScreen's real schedule, built from the same constant the screen + * uses. Boundaries land at 4000 (exhale), 8000 (inhale), 12000 (exhale)... — + * `skipOpening` drops the one at 0, which `sessionStart` occupies. + * + * Module scope, deliberately: `schedule` identity governs the scheduler effect, + * so a fresh array per render would tear the scheduler down and reset the + * session timeline mid-test. + */ +const SCHEDULE: ScheduledCue[] = boundariesWithin(DEFAULT_PATTERN, 180_000, { + skipOpening: true, +}).map((b) => ({ atMs: b.atMs, cue: b.phase })); + +/** A navigator that is not a container: `useIsFocusedSafe` reads the context only. */ +function makeNavigation() { + let focused = true; + const listeners: Record void>> = { focus: new Set(), blur: new Set() }; + + return { + context: { + isFocused: () => focused, + addListener: (type: string, cb: () => void) => { + listeners[type]?.add(cb); + return () => listeners[type]?.delete(cb); + }, + }, + blur: () => { + focused = false; + listeners.blur.forEach((cb) => cb()); + }, + }; +} + +let clock = 0; + +/** Advance the injected monotonic clock and the timer queue together. */ +function advance(ms: number): void { + act(() => { + clock += ms; + jest.advanceTimersByTime(ms); + }); +} + +/** What the screen reader was actually handed. */ +const mockSpeak = AccessibilityInfo.announceForAccessibility as jest.MockedFunction< + typeof AccessibilityInfo.announceForAccessibility +>; + +/** Every cue the practitioner could have felt, of either phase. */ +function feltCueCount(): number { + return mockHaptics.impactAsync.mock.calls.length; +} + +/** The AppState handler the hook registered, so background/foreground is drivable. */ +function appStateHandler(): (next: string) => void { + const calls = (AppState.addEventListener as unknown as jest.Mock).mock.calls; + return calls[calls.length - 1][1]; +} + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + __resetHapticEngineForTest(); + clock = 0; + jest.spyOn(performance, 'now').mockImplementation(() => clock); + mockFlag.mockReturnValue(true); + mockSettings.mockReturnValue({ practiceHaptics: true } as ReturnType); + (AppState as unknown as { currentState: string }).currentState = 'active'; +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +interface Props { + isActive: boolean; + announce?: (cue: string) => void; +} + +function mountPractice(nav: ReturnType, announce?: (cue: string) => void) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + return renderHook( + ({ isActive, announce: a }: Props) => + usePracticeHaptics({ + schedule: SCHEDULE, + isActive, + announce: a as never, + }), + { initialProps: { isActive: false, announce } as Props, wrapper } + ); +} + +describe('DEBUG-587 AC3: the blur gate holds for the whole crisis-screen dwell', () => { + /** + * CONTROL — must stay GREEN across the change. + * + * Without this, a red on the specs below is indistinguishable from a harness + * that never delivers a cue at all. This one proves cues reach expo-haptics + * under exactly the setup the failing specs use. + */ + it('delivers cues normally while the practice screen is focused', () => { + const nav = makeNavigation(); + const { rerender } = mountPractice(nav); + + act(() => rerender({ isActive: true })); + expect(feltCueCount()).toBe(0); + + advance(4000); // exhale boundary + expect(feltCueCount()).toBe(1); + + advance(4000); // inhale boundary + expect(feltCueCount()).toBe(2); + }); + + /** CONTROL — passes before and after; the single-commit half was always correct. */ + it('suppresses the next cue when the practitioner navigates away', () => { + const nav = makeNavigation(); + const { rerender } = mountPractice(nav); + + act(() => rerender({ isActive: true })); + advance(4000); + expect(feltCueCount()).toBe(1); + + act(() => nav.blur()); + + advance(8000); // two boundaries would have passed + expect(feltCueCount()).toBe(1); + }); + + /** + * THE ONE THAT FAILS TODAY, and the one a naive fix will not cover. + * + * A practice screen re-renders about once a second while blurred, because its + * elapsed-time state keeps ticking behind the crisis screen. Each of those + * renders re-ran the render-body write and restored the gate. Moving the + * effect's assignment into a layout effect — the obvious fix — closes the + * intra-commit gap and leaves this wide open. + */ + it('stays suppressed across the elapsed-time re-renders that follow a blur', () => { + const nav = makeNavigation(); + const { rerender } = mountPractice(nav); + + act(() => rerender({ isActive: true })); + advance(4000); + expect(feltCueCount()).toBe(1); + + act(() => nav.blur()); + + // The tick. Nothing about the hook's inputs changed — this is the screen's + // own clock re-rendering it behind CrisisResources. + act(() => rerender({ isActive: true })); + + // Foregrounding after the blur: the app was backgrounded by the dial and + // came back. This is the post-988-call return path. + act(() => appStateHandler()('background')); + act(() => appStateHandler()('active')); + + advance(4000); + expect(feltCueCount()).toBe(1); + }); + + /** + * The same stale gate, reached through the speech channel instead. + * + * A paired announcement is armed on a stagger timer and consults the gate at + * FIRE time, not at schedule time. A cue delivered just before the navigation + * therefore lands its utterance over the crisis screen — and for a VoiceOver + * practitioner it collides with that screen's own announcements. + */ + it('does not speak a boundary that was staggered across the navigation', () => { + const nav = makeNavigation(); + const announce = jest.fn(); + const { rerender } = mountPractice(nav, announce); + + act(() => rerender({ isActive: true, announce })); + + advance(4000); // cue fires; the utterance is now pending on the stagger + expect(announce).not.toHaveBeenCalled(); + + advance(HAPTIC_ANNOUNCEMENT_STAGGER_MS - 50); // still pending + act(() => nav.blur()); + act(() => rerender({ isActive: true, announce })); // the tick + + advance(100); // the stagger comes due, now over the crisis screen + expect(announce).not.toHaveBeenCalled(); + }); +}); + +/** + * The same contract, on a channel that touches no haptics code at all. + * + * `BreathingCircle` speaks every phase through `announceForAccessibility` and had + * no idea whether it was still on screen — so a VoiceOver practitioner who tapped + * 988 kept hearing the practice over CrisisResources. It is not behind + * `practice_haptics`, so unlike everything above it shipped to every VoiceOver + * user on all four screens that render this component. + * + * What is drivable here is the announcement made when the animation effect + * activates. The steady-state per-leg announcements ride `withTiming` completion + * worklets, and the reanimated mock deliberately never invokes those (INFRA-373), + * so they are unobservable in jest on any test anyone could write. Both routes go + * through the one `announcePhase` guard, which is what makes this reachable entry + * point worth pinning. + */ +describe('DEBUG-587: the breath does not speak over a crisis surface', () => { + it('announces the phase while the practice is on screen', () => { + const nav = makeNavigation(); + render( + + + + ); + + expect(mockSpeak).toHaveBeenCalledWith('Breathe in'); + }); + + it('falls silent once the practitioner has navigated away', () => { + const nav = makeNavigation(); + const { rerender } = render( + + + + ); + + act(() => nav.blur()); + act(() => + rerender( + + + + ) + ); + + expect(mockSpeak).not.toHaveBeenCalled(); + }); + + /** + * Silence is the SPEECH channel only. The visible reduced-motion label is + * on-screen state rather than an interruption, so it must still track the + * breath — otherwise a practitioner returning from the crisis screen finds a + * stale phase label waiting for them. + */ + it('keeps the visible reduced-motion cue current while silent', () => { + const nav = makeNavigation(); + const { rerender } = render( + + + + ); + + act(() => nav.blur()); + act(() => + rerender( + + + + ) + ); + + expect(mockSpeak).not.toHaveBeenCalled(); + expect(screen.getByTestId('bc-phase-cue', { includeHiddenElements: true })).toHaveTextContent( + 'Breathe in' + ); + }); +}); diff --git a/app/__tests__/unit/practices/haptics/pauseResumeSync.test.tsx b/app/__tests__/unit/practices/haptics/pauseResumeSync.test.tsx new file mode 100644 index 00000000..b85ba780 --- /dev/null +++ b/app/__tests__/unit/practices/haptics/pauseResumeSync.test.tsx @@ -0,0 +1,177 @@ +/** + * DEBUG-587 AC1/AC2 — the cue timeline and the breath agree across a pause. + * + * THE DECISION THIS FILE PINS (AC2). The scheduler is authoritative and the + * VISUALS were re-anchored to it, not the other way round. `cueScheduler` is + * deliberately pattern-agnostic and is shared with ReflectionTimerScreen's + * interval cadence and BodyScanScreen's region timeline, both of which are + * correct precisely because their targets are absolute against a fixed origin. + * Snapping the scheduler forward on every resume would have made the cue COUNT a + * function of how many times the practitioner paused — the "signature" the cue + * catalog forbids — left the tail of a fixed-length schedule undelivered, and + * reintroduced the accumulating error `phaseAtElapsed` exists to eliminate. + * + * So `schedulerHoldsAbsolutePositions` below is not a happy-path test. It is the + * spec that goes RED if someone later re-anchors the scheduler to the visual + * restart, which is the defensible-looking wrong answer to AC1. + * + * WHY BOTH MODALITIES LIVE IN ONE FILE. The defect is an AGREEMENT defect. A + * file that asserts only what reaches `expo-haptics`, or only what reaches the + * screen reader, cannot fail on a disagreement between them — each side is + * self-consistent. Splitting these into two files would leave the actual bug + * unpinned while both files stayed green. + * + * Note the reanimated mock never invokes a `withTiming` completion callback, so + * the phase announcements that ride those callbacks are unobservable here. The + * one that IS observable is the announcement made when the effect activates — + * which is exactly the line that was wrong. + */ + +import React from 'react'; +import { AccessibilityInfo, AppState } from 'react-native'; +import { render, renderHook, act } from '@testing-library/react-native'; +import * as Haptics from 'expo-haptics'; + +import BreathingCircle from '@/features/practices/shared/components/BreathingCircle'; +import { usePracticeHaptics } from '@/features/practices/shared/haptics/usePracticeHaptics'; +import { __resetHapticEngineForTest } from '@/features/practices/shared/haptics/hapticEngine'; +import { boundariesWithin } from '@/features/practices/shared/haptics/phaseAtElapsed'; +import { DEFAULT_PATTERN } from '@/features/practices/shared/breathingPatterns'; +import type { ScheduledCue } from '@/features/practices/shared/haptics/cueScheduler'; + +jest.mock('@/core/services/featureFlags', () => ({ + isFeatureEnabled: jest.fn(() => true), +})); +jest.mock('@/core/stores/settingsStore', () => ({ + usePracticeSettings: jest.fn(() => ({ practiceHaptics: true })), +})); + +import { isFeatureEnabled } from '@/core/services/featureFlags'; +import { usePracticeSettings } from '@/core/stores/settingsStore'; + +const mockHaptics = Haptics as jest.Mocked; +const mockFlag = isFeatureEnabled as jest.MockedFunction; +const mockSettings = usePracticeSettings as jest.MockedFunction; +const mockAnnounce = AccessibilityInfo.announceForAccessibility as jest.MockedFunction< + typeof AccessibilityInfo.announceForAccessibility +>; + +/** PracticeTimerScreen's real schedule: 4000 exhale, 8000 inhale, 12000 exhale... */ +const SCHEDULE: ScheduledCue[] = boundariesWithin(DEFAULT_PATTERN, 180_000, { + skipOpening: true, +}).map((b) => ({ atMs: b.atMs, cue: b.phase })); + +let clock = 0; + +function advance(ms: number): void { + act(() => { + clock += ms; + jest.advanceTimersByTime(ms); + }); +} + +/** Everything spoken so far, in order. */ +function spoken(): string[] { + return mockAnnounce.mock.calls.map(([text]) => String(text)); +} + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + __resetHapticEngineForTest(); + clock = 0; + jest.spyOn(performance, 'now').mockImplementation(() => clock); + mockFlag.mockReturnValue(true); + mockSettings.mockReturnValue({ practiceHaptics: true } as ReturnType); + (AppState as unknown as { currentState: string }).currentState = 'active'; +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('DEBUG-587 AC2: the scheduler keeps absolute positions across a pause', () => { + it('resumes the cue timeline where the practice left it, not on a fresh cycle', () => { + const { rerender } = renderHook( + ({ isActive }: { isActive: boolean }) => usePracticeHaptics({ schedule: SCHEDULE, isActive }), + { initialProps: { isActive: false } } + ); + + act(() => rerender({ isActive: true })); + + advance(4000); // the exhale boundary + expect(mockHaptics.impactAsync).toHaveBeenCalledTimes(1); + + advance(1000); // 1000 ms into the exhale + act(() => rerender({ isActive: false })); // pause + + advance(10_000); // wall time passes; session time must not + expect(mockHaptics.impactAsync).toHaveBeenCalledTimes(1); + + act(() => rerender({ isActive: true })); // resume at session position 5000 + + // The next boundary is at 5000 + 3000, NOT immediately (which a re-anchored + // scheduler would give) and NOT a full phase away at 4000. + advance(2999); + expect(mockHaptics.impactAsync).toHaveBeenCalledTimes(1); + + advance(1); + expect(mockHaptics.impactAsync).toHaveBeenCalledTimes(2); + expect(mockHaptics.impactAsync).toHaveBeenLastCalledWith(Haptics.ImpactFeedbackStyle.Light); + }); +}); + +describe('DEBUG-587 AC1: the breath resumes into the phase that was running', () => { + /** CONTROL — a genuine start is still the top of an inhale. */ + it('opens on the inhale when the practice actually begins', () => { + render(); + expect(spoken()).toEqual(['Breathe in']); + }); + + /** CONTROL — resuming inside an inhale still says inhale, now for the right reason. */ + it('resumes into the inhale when the pause landed inside one', () => { + const { rerender } = render(); + expect(spoken()).toEqual(['Breathe in']); + + advance(2000); // 2000 ms into the opening inhale + act(() => rerender()); + advance(10_000); // paused + act(() => rerender()); + + expect(spoken()).toEqual(['Breathe in', 'Breathe in']); + }); + + /** + * THE ONE THAT FAILS TODAY. + * + * Pausing 1000 ms into the exhale and resuming told the practitioner to breathe + * IN while the cue timeline — and the session clock the timer is counting — were + * both still inside the exhale. For a VoiceOver practitioner that spoken label is + * the only phase channel there is, so it is not a cosmetic mismatch: it is the + * app instructing the opposite of what it is timing. + */ + it('resumes into the exhale when the pause landed inside one', () => { + const { rerender } = render(); + expect(spoken()).toEqual(['Breathe in']); + + advance(5000); // cycle 0: inhale 0-4000, exhale 4000-8000 → 1000 ms into the exhale + act(() => rerender()); + advance(10_000); // paused; wall time must not advance the breath + act(() => rerender()); + + expect(spoken()).toEqual(['Breathe in', 'Breathe out']); + }); + + /** Paused time is excluded, exactly as `cueScheduler.elapsedMs()` excludes it. */ + it('does not let time spent paused advance the breath', () => { + const { rerender } = render(); + + advance(3000); // still inside the opening inhale + act(() => rerender()); + advance(60_000); // a minute on the crisis screen, say + act(() => rerender()); + + // If paused wall time counted, 63000 ms would land mid-exhale and say so. + expect(spoken()).toEqual(['Breathe in', 'Breathe in']); + }); +}); diff --git a/app/src/features/practices/shared/components/BreathingCircle.tsx b/app/src/features/practices/shared/components/BreathingCircle.tsx index f2edc19b..8a41f366 100644 --- a/app/src/features/practices/shared/components/BreathingCircle.tsx +++ b/app/src/features/practices/shared/components/BreathingCircle.tsx @@ -48,9 +48,29 @@ * `phaseText.hold` label. This component now paces exactly one shape: a * two-phase inhale/exhale pattern, symmetric (4-4) or asymmetric (4-6, the * extended-exhale shape). The full ruling — including what reintroducing - * retention would require — lives in `../breathingPatterns`. + * retention would require — lives in `../breathingPatterns`. * + * THE BREATH RE-ENTERS AT ABSOLUTE ACTIVE-ELAPSED; IT DOES NOT RESTART (DEBUG-587). + * + * Activation used to rebuild a fresh `withRepeat(withSequence(inhale, exhale))` + * from the top of an inhale and announce "Breathe in", however far into the breath + * the practitioner had paused. One pause was enough to put the visible and spoken + * breath a phase away from the cue timeline, which excludes paused time. The + * scheduler was ruled authoritative (see `haptics/cueScheduler`), so the position + * is derived here from `phaseAtElapsed` on the same clock, and the activation + * announcement names the phase actually being resumed into. + * + * Two constraints on anything that touches this path. `scripts/check-breathing-worklet-purity.js` + * (CI) forbids `runOnJS` or a state setter inside `useAnimatedStyle` / + * `useDerivedValue` / `useAnimatedReaction` / `useFrameCallback`, forbids + * `requestAnimationFrame` anywhere in this file, and requires the default export to + * stay `React.memo`-wrapped with its module-scope prop constants intact — a + * `runOnJS` inside a `withTiming` COMPLETION callback is explicitly fine and is what + * the legs below use. And the resume seeds an eased position rather than a linear + * one, so the remainder is re-eased: the velocity is discontinuous at the resume + * instant, deliberately, because what has to be exact is the phase BOUNDARY. */ + import React, { useEffect, useCallback, useRef, useState } from 'react'; import { View, Text, StyleSheet, AccessibilityInfo } from 'react-native'; import Animated, { @@ -66,6 +86,8 @@ import Animated, { import { colorSystem, spacing, typography, borderRadius, semantic } from '@/core/theme'; import { DEFAULT_PATTERN } from '../breathingPatterns'; import { groundingItemForCycle } from '../breathingGuidance'; +import { phaseAtElapsed } from '../haptics/phaseAtElapsed'; +import { useIsFocusedSafe } from '../useIsFocusedSafe'; interface BreathingPattern { inhale: number; // milliseconds @@ -189,6 +211,37 @@ const BreathingCircle: React.FC = ({ // Last phase announced, recorded unconditionally (not only under reduced // motion) so the visible cue can be seeded the instant suppression turns on. const lastPhaseRef = useRef(null); + + /** + * Speech stops at the screen edge (DEBUG-587). + * + * This component announces every breath phase through + * `announceForAccessibility`, and did so with no idea whether it was still on + * screen. A VoiceOver practitioner who tapped the crisis button therefore kept + * hearing "Breathe in" / "Breathe out" over CrisisResources for as long as the + * practice screen stayed mounted behind it. That is flag-independent — this path + * has nothing to do with `practice_haptics` — so it reached every VoiceOver user + * on every one of the four screens that render this component. + * + * The visible reduced-motion cue and `lastPhaseRef` are deliberately still + * updated while blurred: they are on-screen state, not an interruption, and + * leaving them stale would strand a returning practitioner on the wrong label. + */ + const isFocused = useIsFocusedSafe(); + const focusedRef = useRef(isFocused); + focusedRef.current = isFocused; + + /** + * Session position, paused time excluded — the same definition + * `cueScheduler.elapsedMs()` uses, on the same `performance.now()` clock. + * + * This is what lets the breath resume into the phase that was actually running + * instead of restarting at the top of an inhale. It is internal on purpose: the + * two Daily Loop screens that render this component sit on a Protected Path, and + * a new prop would drag them into the diff to buy nothing they need. + */ + const accumulatedActiveMsRef = useRef(0); + const activeSinceRef = useRef(null); useEffect(() => { const wasReduced = reducedMotionRef.current; reducedMotionRef.current = effectiveReducedMotion; @@ -217,9 +270,11 @@ const BreathingCircle: React.FC = ({ // practitioner gets pacing from the animation and, under reduced motion, from // the visible phase label below — never from a sound. const announcePhase = useCallback((phaseText: string) => { - AccessibilityInfo.announceForAccessibility(phaseText); lastPhaseRef.current = phaseText; if (reducedMotionRef.current) setPhaseCue(phaseText); + // DEBUG-587: never speak over a screen the practitioner has navigated to. + if (!focusedRef.current) return; + AccessibilityInfo.announceForAccessibility(phaseText); }, []); // Handle cycle completion on JS thread @@ -279,6 +334,13 @@ const BreathingCircle: React.FC = ({ useEffect(() => { if (!isActive) { + // Fold the closing stretch into the session position before the animation + // is torn down, so a resume knows where the breath actually is (DEBUG-587). + if (activeSinceRef.current !== null) { + accumulatedActiveMsRef.current += performance.now() - activeSinceRef.current; + activeSinceRef.current = null; + } + // Stop all animations and reset to initial state activeRef.value = false; cancelAnimation(scale); @@ -293,10 +355,44 @@ const BreathingCircle: React.FC = ({ // When becoming active, ensure clean state by canceling any existing animations activeRef.value = true; + activeSinceRef.current = performance.now(); cancelAnimation(scale); cancelAnimation(opacity); cancelAnimation(phase); + /** + * RE-ENTER THE BREATH WHERE IT LEFT OFF (DEBUG-587 AC1/AC2). + * + * This effect used to rebuild a fresh `withRepeat(withSequence(inhale, exhale))` + * and announce "Breathe in" on every activation, so one pause was enough to put + * the visible and spoken breath a phase away from the cue timeline — which + * resumes from `accumulatedMs` and excludes paused time. + * + * The ruling was that the SCHEDULER is authoritative and the visuals move to + * meet it. `cueScheduler` is pattern-agnostic and shared with the interval and + * body-region timelines, which are correct precisely because their targets are + * absolute against a fixed origin; snapping it on resume would have made the + * cue count a function of pause history. So the position is derived here from + * the same analytic model the cues use — `phaseAtElapsed`, imported rather than + * re-derived, because two copies of this arithmetic is how the two halves drift + * apart in the first place. + * + * On a genuine start elapsed is 0, which yields the opening inhale at full + * duration and a seed of exactly the resting scale — so cold-start behaviour is + * unchanged, including on the two Daily Loop screens that never pause. + */ + const elapsedMs = accumulatedActiveMsRef.current; + const { phase: resumePhase, phaseStartedAtMs } = phaseAtElapsed(pattern, elapsedMs); + const phaseDurationMs = resumePhase === 'inhale' ? pattern.inhale : pattern.exhale; + const intoPhaseMs = elapsedMs - phaseStartedAtMs; + const remainingMs = Math.max(0, phaseDurationMs - intoPhaseMs); + // Seed at the EASED position, not the linear one, so the circle picks up where + // the eye left it. Re-easing the remainder puts a velocity discontinuity at the + // resume instant; that is accepted — motion resuming after a pause should ease + // in — and what has to be exact is the BOUNDARY, which `remainingMs` carries. + const easeFn = Easing.inOut(Easing.ease); + const easedProgress = phaseDurationMs > 0 ? easeFn(intoPhaseMs / phaseDurationMs) : 0; + // Two-phase inhale/exhale pattern — the only engine (MAINT-391). Scale // expands over `inhale` then contracts over `exhale`, repeating seamlessly: // no inter-cycle gap, which is what lets `haptics/phaseAtElapsed` model the @@ -310,49 +406,68 @@ const BreathingCircle: React.FC = ({ const inhaleLabel = phaseText.inhale || 'Breathe in'; const exhaleLabel = phaseText.exhale || 'Breathe out'; - scale.value = withRepeat( - withSequence( - withTiming( - 1.5, - { duration: pattern.inhale, easing: Easing.inOut(Easing.ease) }, - (finished) => { - 'worklet'; - // Contraction begins → announce exhale. - if (finished && activeRef.value) { - runOnJS(announcePhase)(exhaleLabel); - } - } - ), - withTiming( - 1, - { duration: pattern.exhale, easing: Easing.inOut(Easing.ease) }, - (finished) => { - 'worklet'; - // Cycle end → count it once, then cue the next inhale (the repeat - // loops straight into the next expansion). - if (finished && activeRef.value) { - runOnJS(handleCycleComplete)(); - runOnJS(announcePhase)(inhaleLabel); - } - } + // Each leg is a factory so the partial resume leg and the steady-state legs + // stay one definition — the completion callbacks are the load-bearing part and + // must not be written twice. + const inhaleScaleLeg = (durationMs: number) => + withTiming(1.5, { duration: durationMs, easing: Easing.inOut(Easing.ease) }, (finished) => { + 'worklet'; + // Contraction begins → announce exhale. + if (finished && activeRef.value) { + runOnJS(announcePhase)(exhaleLabel); + } + }); + const exhaleScaleLeg = (durationMs: number) => + withTiming(1, { duration: durationMs, easing: Easing.inOut(Easing.ease) }, (finished) => { + 'worklet'; + // Cycle end → count it once, then cue the next inhale (the repeat loops + // straight into the next expansion). + if (finished && activeRef.value) { + runOnJS(handleCycleComplete)(); + runOnJS(announcePhase)(inhaleLabel); + } + }); + const inhaleOpacityLeg = (durationMs: number) => + withTiming(1, { duration: durationMs, easing: Easing.inOut(Easing.ease) }); + const exhaleOpacityLeg = (durationMs: number) => + withTiming(0.8, { duration: durationMs, easing: Easing.inOut(Easing.ease) }); + + if (resumePhase === 'inhale') { + scale.value = 1 + 0.5 * easedProgress; + opacity.value = 0.8 + 0.2 * easedProgress; + scale.value = withSequence( + inhaleScaleLeg(remainingMs), + withRepeat(withSequence(exhaleScaleLeg(pattern.exhale), inhaleScaleLeg(pattern.inhale)), -1, false) + ); + opacity.value = withSequence( + inhaleOpacityLeg(remainingMs), + withRepeat( + withSequence(exhaleOpacityLeg(pattern.exhale), inhaleOpacityLeg(pattern.inhale)), + -1, + false ) - ), - -1, - false - ); - - opacity.value = withRepeat( - withSequence( - withTiming(1, { duration: pattern.inhale, easing: Easing.inOut(Easing.ease) }), - withTiming(0.8, { duration: pattern.exhale, easing: Easing.inOut(Easing.ease) }) - ), - -1, - false - ); + ); + } else { + scale.value = 1.5 - 0.5 * easedProgress; + opacity.value = 1 - 0.2 * easedProgress; + scale.value = withSequence( + exhaleScaleLeg(remainingMs), + withRepeat(withSequence(inhaleScaleLeg(pattern.inhale), exhaleScaleLeg(pattern.exhale)), -1, false) + ); + opacity.value = withSequence( + exhaleOpacityLeg(remainingMs), + withRepeat( + withSequence(inhaleOpacityLeg(pattern.inhale), exhaleOpacityLeg(pattern.exhale)), + -1, + false + ) + ); + } - // Immediate first inhale cue on activation (subsequent inhale cues come - // from the exhale-leg completion callback above). - announcePhase(inhaleLabel); + // Announce the phase actually being entered. On a genuine start that is the + // opening inhale, exactly as before; on a resume it is whatever the session + // clock says is running, which is the half that was lying. + announcePhase(resumePhase === 'inhale' ? inhaleLabel : exhaleLabel); return () => { cancelAnimation(scale); diff --git a/app/src/features/practices/shared/haptics/cueScheduler.ts b/app/src/features/practices/shared/haptics/cueScheduler.ts index f4143671..ba991543 100644 --- a/app/src/features/practices/shared/haptics/cueScheduler.ts +++ b/app/src/features/practices/shared/haptics/cueScheduler.ts @@ -19,9 +19,29 @@ * per-cue and non-accumulating, which is the whole reason `phaseAtElapsed` * exists. * - * Late cues are DROPPED, not fired late. See MAX_CUE_LATENESS_MS. + * Late cues are DROPPED, not fired late. See MAX_CUE_LATENESS_MS. * + * THE SCHEDULER IS AUTHORITATIVE FOR CROSS-MODAL AGREEMENT (DEBUG-587). + * + * When the cue timeline and the visible breath disagreed across a pause, the + * ruling was that the VISUALS move to meet this module, not the other way round. + * `BreathingCircle` now derives its resume position from `phaseAtElapsed` on the + * same pause-excluding clock this module uses. + * + * Re-anchoring the scheduler to the visual restart was considered and REFUSED. + * This module is deliberately pattern-agnostic — it consumes an opaque sorted + * `ScheduledCue[]` and knows nothing about inhale or exhale — and it is shared + * with ReflectionTimerScreen's interval cadence and BodyScanScreen's region + * timeline, both correct precisely because their targets are absolute against a + * fixed origin. Snapping forward on resume would have: made the cue COUNT a + * function of how many times the practitioner paused, which is the "signature" + * the cue catalog forbids; left the tail of a fixed-length schedule undelivered, + * thinning an eyes-closed practitioner's pacing toward the end of a practice with + * no signal; and reintroduced the accumulating error `phaseAtElapsed` exists to + * eliminate. `__tests__/unit/practices/haptics/pauseResumeSync.test.tsx` pins the + * decision — it goes red if the scheduler is ever re-anchored. */ + import type { PracticeCue } from './cueCatalog'; import { MAX_CUE_LATENESS_MS } from './constants'; diff --git a/app/src/features/practices/shared/haptics/phaseAtElapsed.ts b/app/src/features/practices/shared/haptics/phaseAtElapsed.ts index 73183e85..3f7b75fd 100644 --- a/app/src/features/practices/shared/haptics/phaseAtElapsed.ts +++ b/app/src/features/practices/shared/haptics/phaseAtElapsed.ts @@ -23,6 +23,11 @@ * * The pattern may be asymmetric (`{ inhale: 4000, exhale: 6000 }`); nothing here * assumes the two phases are equal. + * + * SINCE DEBUG-587 THE ANIMATION CONSUMES THIS MODEL TOO, at activation, to decide + * which phase a resume re-enters. That is what keeps the two halves honest: the + * visuals and the cues now read one definition of "where in the breath are we" + * rather than each carrying their own arithmetic. */ export type BreathPhase = 'inhale' | 'exhale'; diff --git a/app/src/features/practices/shared/haptics/usePracticeHaptics.ts b/app/src/features/practices/shared/haptics/usePracticeHaptics.ts index f17391b8..b1515725 100644 --- a/app/src/features/practices/shared/haptics/usePracticeHaptics.ts +++ b/app/src/features/practices/shared/haptics/usePracticeHaptics.ts @@ -33,16 +33,28 @@ * that fires after the practitioner has navigated away is at best confusing, * and the crisis button is reachable from every practice screen — no haptic may * fire on or over a crisis surface. + * + * THE ONE RESIDUAL, WITH ITS BOUND (DEBUG-587). React Navigation emits `blur` + * from an effect AFTER the incoming screen's push has committed, so between + * CrisisResources committing and `focused` going false there is a 1-3 frame gap + * that no assignment-timing change inside this hook can close — it is upstream of + * every signal the hook has. It is partially masked by the stack push animation. + * That gap is RECORDED, not fixed. Nothing else here is: the every-tick gate + * re-open, the AppState re-arm on returning from a 988 call, and the uncleared + * stagger timers were all defects and are all closed, pinned by + * `__tests__/unit/practices/haptics/crisisBlurGate.test.tsx`. Closing the residual + * too would need the crisis press itself to publish a suppression signal that + * practice surfaces read; that is a design change, not a timing one. */ -import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { AppState, type AppStateStatus, Platform } from 'react-native'; -import { NavigationContext } from '@react-navigation/native'; import { isFeatureEnabled } from '@/core/services/featureFlags'; import { logAccessibility } from '@/core/services/logging'; import { usePracticeSettings } from '@/core/stores/settingsStore'; +import { useIsFocusedSafe } from '../useIsFocusedSafe'; import { createHapticEngine } from './hapticEngine'; import { createCueScheduler, type ScheduledCue } from './cueScheduler'; import { HAPTIC_ANNOUNCEMENT_STAGGER_MS } from './constants'; @@ -87,39 +99,6 @@ export interface UsePracticeHapticsReturn { emitSessionEnd: () => void; } -/** - * Navigation focus, WITHOUT requiring a navigation container. - * - * `useIsFocused` throws outright when there is no navigator above it. That - * would make this hook — and therefore every practice screen using it — - * unrenderable outside a NavigationContainer, which is how most of the existing - * practice screen tests mount them, and would be a hard crash rather than a - * degraded experience anywhere a practice is embedded directly. - * - * Reading the context instead lets the hook degrade honestly: inside a - * navigator it tracks focus and blur properly; outside one it reports focused, - * because there is no navigation state that could say otherwise. - */ -function useIsFocusedSafe(): boolean { - const navigation = useContext(NavigationContext); - const [focused, setFocused] = useState(true); - - useEffect(() => { - if (!navigation) return undefined; - - setFocused(navigation.isFocused()); - const unsubscribeFocus = navigation.addListener('focus', () => setFocused(true)); - const unsubscribeBlur = navigation.addListener('blur', () => setFocused(false)); - - return () => { - unsubscribeFocus(); - unsubscribeBlur(); - }; - }, [navigation]); - - return focused; -} - export function usePracticeHaptics({ schedule, isActive, @@ -148,6 +127,16 @@ export function usePracticeHaptics({ // Refs so the effect below does not re-run (and tear down the scheduler) every // time one of these changes. + // + // ONE WRITER, ONE MEANING (DEBUG-587). Each of these is assigned exactly once, + // here in the render body, and carries exactly the value its name says. The + // scheduled gate is COMPOSED from them at read time (`isRunning` below), never + // pre-combined into a ref. It used to be: `activeRef` was written here as raw + // `isActive` and again in the focus effect as `isActive && isFocused`, so the + // two writers disagreed and the last one to run won. Since a blurred practice + // screen keeps re-rendering on its elapsed-time tick — and the effect's deps do + // not change when it does — the render-body write restored the gate about once a + // second for the entire time the practitioner sat on the crisis screen. const enabledRef = useRef(tactileEnabled); enabledRef.current = tactileEnabled; const activeRef = useRef(isActive); @@ -160,6 +149,17 @@ export function usePracticeHaptics({ anchorsRef.current = sessionAnchors; const schedulerRef = useRef | null>(null); + /** + * Cancels any announcement still waiting out its stagger (DEBUG-587). + * + * The handles live in the scheduler effect's closure, but the transition that + * must cancel them — pause, or a navigation away — is observed by the focus + * effect below. Pausing the scheduler is not enough on its own: a stagger timer + * is already armed and consults the gate when it FIRES, so an utterance + * scheduled up to HAPTIC_ANNOUNCEMENT_STAGGER_MS before the navigation would + * still land, on the crisis screen, with a correct gate. + */ + const clearPendingSpeechRef = useRef<(() => void) | null>(null); /** * THE SESSION ANCHORS (FEAT-311) — a second, imperative path. @@ -246,6 +246,24 @@ export function usePracticeHaptics({ /** Pending announcement timers, so a stray utterance cannot outlive us. */ const staggerHandles = new Set>(); + /** + * The scheduled channels' gate, composed at read time from single-writer refs. + * + * Deliberately shaped like the anchor engine's gate above rather than reading + * one pre-combined ref: a ref that means two things is a ref two code paths + * can disagree about, which is the DEBUG-587 defect exactly. Note what is + * absent — `enabledRef`. Declining vibration must not silence the paired + * speech (DEBUG-425), so the tactile preference is ANDed in at the tactile + * call site only, never folded in here. + */ + const isRunning = (): boolean => activeRef.current && focusedRef.current; + + const clearPendingSpeech = (): void => { + staggerHandles.forEach(clearTimeout); + staggerHandles.clear(); + }; + clearPendingSpeechRef.current = clearPendingSpeech; + /** * Speak the boundary. * @@ -259,20 +277,20 @@ export function usePracticeHaptics({ if (!announceFn) return; if (!enabledRef.current) { - if (activeRef.current) announceFn(cue); + if (isRunning()) announceFn(cue); return; } const handle = setTimeout(() => { staggerHandles.delete(handle); - if (activeRef.current) announceFn(cue); + if (isRunning()) announceFn(cue); }, HAPTIC_ANNOUNCEMENT_STAGGER_MS); staggerHandles.add(handle); }; const engine = createHapticEngine({ // Re-read on every cue: the practitioner may revoke mid-session. - isEnabled: () => enabledRef.current && activeRef.current, + isEnabled: () => enabledRef.current && isRunning(), platform: Platform.OS === 'ios' ? 'ios' : 'android', }); @@ -319,7 +337,14 @@ export function usePracticeHaptics({ if (next === 'active') { // Re-arm from the current position. The scheduler drops every boundary // that went stale while suspended, so this emits nothing itself. - if (activeRef.current) scheduler.start(); + // + // The focus term is load-bearing (DEBUG-587), not defensive symmetry. + // Dialling 988 from CrisisResources backgrounds the app, so THIS is the + // handler that runs when the practitioner comes back from the call — and + // with a gate that could not stay closed, it re-armed the scheduler onto a + // practice screen sitting behind the crisis surface, for the rest of the + // session. + if (isRunning()) scheduler.start(); } else { scheduler.pause(); } @@ -332,8 +357,8 @@ export function usePracticeHaptics({ subscription.remove(); scheduler.stop(); schedulerRef.current = null; - staggerHandles.forEach(clearTimeout); - staggerHandles.clear(); + clearPendingSpeech(); + clearPendingSpeechRef.current = null; }; // `schedule` identity governs the session; callers must memoise it. // @@ -356,7 +381,12 @@ export function usePracticeHaptics({ */ useEffect(() => { const running = isActive && isFocused; - activeRef.current = running; + + // No assignment here. `activeRef` and `focusedRef` are each written once, in + // the render body, and the gate composes them at read time — see the + // one-writer-one-meaning note above. Writing a combined value here is what + // made the gate re-openable by an unrelated re-render (DEBUG-587). + if (!running) clearPendingSpeechRef.current?.(); const scheduler = schedulerRef.current; if (!scheduler) return; diff --git a/app/src/features/practices/shared/useIsFocusedSafe.ts b/app/src/features/practices/shared/useIsFocusedSafe.ts new file mode 100644 index 00000000..e5ba1f42 --- /dev/null +++ b/app/src/features/practices/shared/useIsFocusedSafe.ts @@ -0,0 +1,49 @@ +/** + * useIsFocusedSafe — navigation focus, WITHOUT requiring a navigation container. + * + * `useIsFocused` throws outright when there is no navigator above it. That would + * make every consumer unrenderable outside a NavigationContainer — which is how + * most practice-screen tests mount them, and would be a hard crash rather than a + * degraded experience anywhere a practice is embedded directly. + * + * Reading the context instead lets a consumer degrade honestly: inside a + * navigator it tracks focus and blur properly; outside one it reports focused, + * because there is no navigation state that could say otherwise. + * + * WHY IT LIVES HERE RATHER THAN IN THE HOOK THAT FIRST NEEDED IT (DEBUG-587). + * Two practice surfaces emit while the practitioner may have navigated away, and + * they are independent: `usePracticeHaptics` routes the tactile and paired-speech + * channels, and `BreathingCircle` speaks every breath phase through + * `AccessibilityInfo.announceForAccessibility` on a path that touches no haptics + * code at all. Both must fall silent on the same signal, so they read one + * implementation of it. A second copy would be a second thing to get wrong, on a + * gate whose whole purpose is that nothing from a practice reaches a crisis + * surface. + * + * Deliberately NOT in `core/hooks/`: that directory is a Protected Path, so + * putting it there would arm the simulator safety gate on every future edit to a + * helper whose consumers are all inside `features/practices/`. + */ + +import { useContext, useEffect, useState } from 'react'; +import { NavigationContext } from '@react-navigation/native'; + +export function useIsFocusedSafe(): boolean { + const navigation = useContext(NavigationContext); + const [focused, setFocused] = useState(true); + + useEffect(() => { + if (!navigation) return undefined; + + setFocused(navigation.isFocused()); + const unsubscribeFocus = navigation.addListener('focus', () => setFocused(true)); + const unsubscribeBlur = navigation.addListener('blur', () => setFocused(false)); + + return () => { + unsubscribeFocus(); + unsubscribeBlur(); + }; + }, [navigation]); + + return focused; +}