diff --git a/app/__tests__/unit/practices/practiceNavigation.contract.test.ts b/app/__tests__/unit/practices/practiceNavigation.contract.test.ts new file mode 100644 index 00000000..0d66ed89 --- /dev/null +++ b/app/__tests__/unit/practices/practiceNavigation.contract.test.ts @@ -0,0 +1,190 @@ +/** + * FEAT-293 CONTRACT — standalone practice discoverability. + * + * AC3 of FEAT-293 is "no regression to existing Learn-launched entry points", + * and before this work item that AC had NO mechanical pin whatsoever: zero tests + * in the repo referenced PracticeTab or the launch switch it owned. Promoting + * the practices to a second entry point without pinning the first is exactly how + * the regression would have arrived unnoticed. + * + * The launch switch is now the pure `resolvePracticeRoute`, shared by Learn and + * the new Practice Library. These cases lock its output to what the original + * inline switch produced, param for param — including the default durations, + * which were previously magic numbers inside PracticeTab. + * + * Also pins two philosopher constraints that would otherwise degrade silently: + * the drill's citation must resolve to Epictetus (not fall back to the breathing + * quote), and every catalog entry must be filed under its OWN principle. + */ + +import { resolvePracticeRoute } from '@/features/practices/catalog/practiceNavigation'; +import { + STANDALONE_PRACTICES, + FEATURED_PRACTICE, +} from '@/features/practices/catalog/standalonePractices'; +import { PRACTICE_QUOTES } from '@/features/learn/practices/PracticeCompletionScreen'; +import type { ModuleId, Practice } from '@/features/learn/types/education'; + +const MODULE: ModuleId = 'sphere-sovereignty'; + +const practice = (over: Partial): Practice => + ({ + id: 'p1', + title: 'A Practice', + description: 'A description', + type: 'guided-timer', + ...over, + }) as Practice; + +describe('FEAT-293 — Learn launch contract (AC3 regression pin)', () => { + it('guided-timer → PracticeTimer, preserving the 180s default', () => { + const { screen, params } = resolvePracticeRoute( + practice({ id: 'breathing-space', type: 'guided-timer', title: 'Breathing' }), + MODULE + ); + expect(screen).toBe('PracticeTimer'); + expect(params).toEqual({ + practiceId: 'breathing-space', + moduleId: MODULE, + duration: 180, + title: 'Breathing', + }); + }); + + it('body-scan → BodyScan, preserving the 300s default', () => { + const { screen, params } = resolvePracticeRoute( + practice({ id: 'body-scan', type: 'body-scan' }), + MODULE + ); + expect(screen).toBe('BodyScan'); + expect(params).toMatchObject({ duration: 300 }); + }); + + it('reflection → ReflectionTimer, mapping description→prompt and passing instructions', () => { + const { screen, params } = resolvePracticeRoute( + practice({ + id: 'reserve-clause', + type: 'reflection', + description: 'Reflect on this', + instructions: ['one', 'two'], + duration: 240, + }), + MODULE + ); + expect(screen).toBe('ReflectionTimer'); + expect(params).toMatchObject({ + prompt: 'Reflect on this', + instructions: ['one', 'two'], + duration: 240, + }); + }); + + it('omits `instructions` entirely when the practice has none', () => { + // exactOptionalPropertyTypes is on; passing `instructions: undefined` is a + // different thing from omitting it, and the original switch omitted it. + const { params } = resolvePracticeRoute( + practice({ type: 'reflection', description: 'x' }), + MODULE + ); + expect(params).not.toHaveProperty('instructions'); + }); + + it('guided-body-scan → GuidedBodyScan', () => { + const { screen } = resolvePracticeRoute( + practice({ type: 'guided-body-scan' }), + MODULE + ); + expect(screen).toBe('GuidedBodyScan'); + }); + + it('an unknown type still falls back to the timer, as the original switch did', () => { + const { screen, params } = resolvePracticeRoute( + practice({ type: 'not-a-real-type' as Practice['type'] }), + MODULE + ); + expect(screen).toBe('PracticeTimer'); + expect(params).toMatchObject({ duration: 180 }); + }); + + it('honours an authored duration over the default', () => { + const { params } = resolvePracticeRoute( + practice({ type: 'guided-timer', duration: 480 }), + MODULE + ); + expect(params).toMatchObject({ duration: 480 }); + }); +}); + +describe('FEAT-293 — sorting launches from BOTH entry points', () => { + it('passes Learn’s already-loaded scenarios straight through', () => { + const scenarios = [{ id: 's1' }, { id: 's2' }] as never; + const { screen, params } = resolvePracticeRoute( + practice({ id: 'control-sorting', type: 'sorting', scenarios }), + MODULE + ); + expect(screen).toBe('SortingPractice'); + expect(params).toMatchObject({ scenarios }); + }); + + it('OMITS scenarios when the caller has none, so the screen self-loads', () => { + // The library and the /sorting deep link cannot supply an array. Before + // FEAT-293 the switch bailed out with a console.warn and navigated nowhere, + // which is why that deep link never worked. + const { screen, params } = resolvePracticeRoute( + practice({ id: 'control-sorting', type: 'sorting' }), + MODULE + ); + expect(screen).toBe('SortingPractice'); + expect(params).not.toHaveProperty('scenarios'); + expect(params).toMatchObject({ practiceId: 'control-sorting', moduleId: MODULE }); + }); + + it('treats an empty scenarios array as "not supplied"', () => { + const { params } = resolvePracticeRoute( + practice({ type: 'sorting', scenarios: [] as never }), + MODULE + ); + expect(params).not.toHaveProperty('scenarios'); + }); +}); + +describe('FEAT-293 — philosopher constraints on the promoted surface', () => { + it('the drill’s citation resolves to Epictetus, NOT the breathing fallback', () => { + // usePracticeCompletion falls back to PRACTICE_QUOTES['breathing-space'] for + // an unknown practiceId. On a Stoic drill that silent fallback would be a + // citation error, so it must fail here rather than degrade quietly. + const quote = PRACTICE_QUOTES[FEATURED_PRACTICE.practiceId]; + expect(quote).toBeDefined(); + expect(quote?.author).toBe('Epictetus'); + expect(quote?.source).toBe('Enchiridion 1'); + expect(quote).not.toEqual(PRACTICE_QUOTES['breathing-space']); + }); + + it('files every catalog entry under its OWN principle', () => { + // Breathing and body scan are the Aware Presence limb and must never be + // presented as classical Stoic technique; the reserve clause is + // hypexhairesis and belongs to Sphere Sovereignty. Filing a practice under a + // principle it does not belong to would assert a false principle→practice + // mapping on a surface users read as canonical — and would propagate into + // the Insights principle-engagement chart. + const PRINCIPLE_FOR_MODULE: Record = { + 'aware-presence': 'aware_presence', + 'radical-acceptance': 'radical_acceptance', + 'sphere-sovereignty': 'sphere_sovereignty', + 'virtuous-response': 'virtuous_response', + 'interconnected-living': 'interconnected_living', + }; + + for (const ref of STANDALONE_PRACTICES) { + expect(ref.principleKey).toBe(PRINCIPLE_FOR_MODULE[ref.moduleId]); + } + }); + + it('features the sorting drill, and features it exactly once', () => { + expect(FEATURED_PRACTICE.practiceId).toBe('control-sorting'); + const occurrences = STANDALONE_PRACTICES.filter( + (p) => p.practiceId === FEATURED_PRACTICE.practiceId + ); + expect(occurrences).toHaveLength(1); + }); +}); diff --git a/app/__tests__/unit/practices/sortingPracticeRoute.test.tsx b/app/__tests__/unit/practices/sortingPracticeRoute.test.tsx new file mode 100644 index 00000000..0ce6272d --- /dev/null +++ b/app/__tests__/unit/practices/sortingPracticeRoute.test.tsx @@ -0,0 +1,133 @@ +/** + * FEAT-293 — SortingPracticeRoute (scenario-resolving wrapper). + * + * This wrapper is now the rendered component for the EXISTING `SortingPractice` + * route, so a bug here breaks the Learn-launched sorting practice that shipped + * long before FEAT-293. That risk is invisible to the Maestro safety suite: + * crisis-button-reachability.yaml deliberately does not walk practice flows + * (they need the long check-in preamble), so no on-device gate exercises this + * path. It has to be pinned here. + * + * The central guarantee is the Learn path: when the caller already holds the + * scenarios, the wrapper must pass them straight through with NO module load and + * NO loading state — same render, same frame, as before FEAT-293. + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +const mockLoadModuleContent = jest.fn(); +jest.mock('@/core/services/moduleContent', () => ({ + loadModuleContent: (...args: unknown[]) => mockLoadModuleContent(...args), +})); + +// Stub the practice screen: this suite is about the wrapper's resolution logic, +// not the drill's rendering (which has its own philosopher-signed copy). +jest.mock('@/features/learn/practices/SortingPracticeScreen', () => { + const { Text: RNText } = require('react-native'); + return { + __esModule: true, + default: ({ scenarios }: { scenarios: Array<{ id: string }> }) => ( + {scenarios.map((s) => s.id).join(',')} + ), + }; +}); + +import SortingPracticeRoute from '@/features/practices/catalog/SortingPracticeRoute'; + +const SCENARIOS = [{ id: 'a' }, { id: 'b' }] as never; + +const moduleWith = (scenarios: unknown) => ({ + practices: [{ id: 'control-sorting', type: 'sorting', scenarios }], +}); + +beforeEach(() => { + mockLoadModuleContent.mockReset(); +}); + +describe('FEAT-293 — Learn path (scenarios supplied) must not regress', () => { + it('renders the drill immediately and never loads module content', () => { + const { getByTestId, queryByTestId } = render( + + ); + + expect(getByTestId('sorting-screen').props.children).toBe('a,b'); + // No spinner frame: Learn's launch must look exactly as it did before. + expect(queryByTestId('sorting-practice-loading')).toBeNull(); + expect(mockLoadModuleContent).not.toHaveBeenCalled(); + }); +}); + +describe('FEAT-293 — standalone path (scenarios omitted) self-loads', () => { + it('shows a loading state, then renders the loaded scenarios', async () => { + mockLoadModuleContent.mockResolvedValue(moduleWith([{ id: 'x' }, { id: 'y' }])); + + const { getByTestId } = render( + + ); + + expect(getByTestId('sorting-practice-loading')).toBeTruthy(); + await waitFor(() => + expect(getByTestId('sorting-screen').props.children).toBe('x,y') + ); + expect(mockLoadModuleContent).toHaveBeenCalledWith('sphere-sovereignty'); + }); + + it('treats an empty scenarios array as unavailable rather than rendering an empty drill', async () => { + // SortingPracticeScreen throws on a missing scenario at the current index, + // so an empty array must never reach it. + mockLoadModuleContent.mockResolvedValue(moduleWith([])); + + const { getByTestId } = render( + + ); + + await waitFor(() => expect(getByTestId('sorting-practice-unavailable')).toBeTruthy()); + }); + + it('degrades to an unavailable message when the practice id is not in the module', async () => { + mockLoadModuleContent.mockResolvedValue({ practices: [] }); + + const { getByTestId } = render( + + ); + + await waitFor(() => expect(getByTestId('sorting-practice-unavailable')).toBeTruthy()); + }); + + it('degrades to an unavailable message when the load rejects — never an unhandled rejection', async () => { + mockLoadModuleContent.mockRejectedValue(new Error('disk on fire')); + + const { getByTestId } = render( + + ); + + await waitFor(() => expect(getByTestId('sorting-practice-unavailable')).toBeTruthy()); + }); + + it('does not set state after unmount when the load resolves late', async () => { + // Dismissing the modal mid-load must not warn or throw. + let resolve!: (v: unknown) => void; + mockLoadModuleContent.mockReturnValue( + new Promise((r) => { + resolve = r; + }) + ); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const { unmount } = render( + + ); + unmount(); + resolve(moduleWith([{ id: 'late' }])); + await waitFor(() => expect(mockLoadModuleContent).toHaveBeenCalled()); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); diff --git a/app/assets/modules/module-3-sphere-sovereignty.json b/app/assets/modules/module-3-sphere-sovereignty.json index f817958c..e7f65716 100644 --- a/app/assets/modules/module-3-sphere-sovereignty.json +++ b/app/assets/modules/module-3-sphere-sovereignty.json @@ -70,7 +70,7 @@ "correctAnswer": "not-in-control", "explanation": "Other drivers' actions and the traffic situation are not in your control.", "inControl": [ - "Your emotional response (staying calm vs. getting angry)", + "How you handle the anger once it flares (steadying vs. escalating)", "How you drive in response (safely vs. recklessly)", "Your judgment about the situation", "Calling ahead to inform you'll be late", @@ -78,6 +78,7 @@ "Your breathing and stress management" ], "notInControl": [ + "The first flash of anger (it appears automatically)", "The other driver's behavior", "The traffic conditions", "Being late (it's already happening)", @@ -86,18 +87,18 @@ }, { "id": "medical-test-anxiety", - "text": "Your anxiety about an upcoming medical test result.", + "text": "How you respond to anxiety while waiting for a medical test result.", "correctAnswer": "in-control", - "explanation": "Your anxiety itself (your judgment/response) is in your control, though the test result is not.", + "explanation": "How you respond to the anxiety is in your control. The first wave of fear is not — it arrives before you can choose. Neither is the result itself; it was never yours to decide.", "inControl": [ - "Your anxiety (it's your judgment/mental response)", - "How you work with the anxiety (breathing, grounding)", + "How you work with the anxiety once you notice it (breathing, grounding)", "Your self-talk and interpretations", "Whether you seek support while waiting", - "Activities you choose to distract or soothe yourself", - "Your preparation for different possible outcomes" + "Activities you choose while you wait (steadying yourself, not avoiding)", + "Thinking through what you'd do next, whichever way it goes" ], "notInControl": [ + "The first wave of fear (it appears automatically)", "The medical test result itself", "When you receive the results", "The medical facts of your condition", @@ -155,7 +156,7 @@ "Your attitude about the situation", "Whether you bring rain gear", "How you make the best of circumstances", - "Finding enjoyment despite the rain" + "Where you put your attention despite the rain" ], "notInControl": [ "The actual weather", @@ -188,16 +189,17 @@ "id": "event-interpretation", "text": "Your interpretation of a difficult event that happened.", "correctAnswer": "in-control", - "explanation": "Your interpretations and judgments are in your control—this is a core Stoic teaching.", + "explanation": "Your interpretations and judgments are in your control — this is a core Stoic teaching. The feeling that arrives before you've interpreted anything is not.", "inControl": [ "The meaning you assign to the event", "Your perspective and framing", - "Your emotional response (based on interpretation)", + "How the feeling shifts once you revisit the interpretation", "The story you tell yourself about it", "Whether you reframe or reinterpret", "How you choose to think about it" ], "notInControl": [ + "The feeling that arrives before you've interpreted anything", "The event itself (it already happened)", "Others' interpretations of the event", "The objective facts of what occurred", @@ -269,9 +271,9 @@ }, { "id": "political-response", - "text": "Your emotional response when hearing those political beliefs.", + "text": "How you respond when you hear those political beliefs.", "correctAnswer": "in-control", - "explanation": "Your responses—emotional and behavioral—are within your control, though they may require practice.", + "explanation": "How you respond is in your control — the tone you take, whether you engage, what you make it mean. The first flash of feeling is not; it arrives before you can choose.", "inControl": [ "How you regulate your emotional reaction", "Your interpretation of their words", @@ -281,7 +283,7 @@ "How you choose to think about the situation" ], "notInControl": [ - "Your initial emotional flash (appears automatically)", + "The first flash of feeling (it appears automatically)", "Their words and delivery", "The content of their beliefs", "The political climate creating tension" diff --git a/app/src/core/navigation/CleanRootNavigator.tsx b/app/src/core/navigation/CleanRootNavigator.tsx index cf43e04c..add632fb 100644 --- a/app/src/core/navigation/CleanRootNavigator.tsx +++ b/app/src/core/navigation/CleanRootNavigator.tsx @@ -33,10 +33,12 @@ import PassageReaderScreen from '@/features/library/screens/PassageReaderScreen' import { PracticeTimerScreen, ReflectionTimerScreen, - SortingPracticeScreen, BodyScanScreen, GuidedBodyScanScreen } from '@/features/learn/practices'; +// FEAT-293: standalone practice discoverability. +import SortingPracticeRoute from '@/features/practices/catalog/SortingPracticeRoute'; +import PracticeLibraryScreen from '@/features/practices/screens/PracticeLibraryScreen'; import { useStoicPracticeStore } from '@/features/practices/stores/stoicPracticeStore'; import { useSettingsStore } from '@/core/stores/settingsStore'; import { useConsentStore } from '@/core/stores/consentStore'; @@ -76,8 +78,16 @@ export type RootStackParamList = { SortingPractice: { practiceId: string; moduleId: ModuleId; - scenarios: SortingScenario[]; + // FEAT-293: OPTIONAL. Learn still passes the already-loaded scenarios + // (unchanged); the standalone Practice Library omits them and the screen + // self-loads from module content. This also repairs the pre-existing + // `/sorting` deep link in linking.ts, which could never supply an array. + scenarios?: SortingScenario[]; }; + // FEAT-293: standalone practice discoverability. A listing surface, so it is + // deliberately absent from RootCrisisButton's SUPPRESSED_ROUTES and + // IMMERSIVE_ROUTES — it must resolve to the default `standard` crisis overlay. + PracticeLibrary: undefined; BodyScan: { practiceId: string; moduleId: ModuleId; @@ -371,6 +381,27 @@ const CleanRootNavigator: React.FC = () => { )} + {/* FEAT-293: standalone practice discoverability. `card` presentation, + NOT modal — it is a browsable listing surface, and it must keep the + root crisis overlay in its default `standard` mode (hence its + deliberate absence from RootCrisisButton's route sets). */} + + {({ navigation }) => ( + navigation.goBack()} + onOpenPractice={(screen, params) => + navigation.navigate(screen as never, params as never) + } + onOpenModule={(moduleId) => + navigation.navigate('ModuleDetail', { moduleId }) + } + /> + )} + + { }} > {({ navigation, route }) => ( - { expect(receivedProps[0]?.testID).toBe(ROOT_CRISIS_BUTTON_TEST_ID); }); + it('keeps the overlay in standard mode on the FEAT-293 PracticeLibrary route', () => { + // PracticeLibrary is a browsable LISTING surface, not a practice the user is + // immersed in, so it must resolve to the default `standard` overlay — and it + // must never end up in SUPPRESSED_ROUTES, which would make it a screen with + // zero 988 access. Unknown routes fall through to `standard`, which is the + // fail-safe direction, but "safe by accident" is not a contract; this pins it. + const { getByTestId } = render(); + expect(getByTestId(ROOT_CRISIS_BUTTON_TEST_ID)).toBeTruthy(); + expect(receivedProps[0]?.mode).toBe('standard'); + }); + it('renders in standard mode when route is undefined (pre-ready)', () => { const { getByTestId } = render(); expect(getByTestId(ROOT_CRISIS_BUTTON_TEST_ID)).toBeTruthy(); diff --git a/app/src/features/home/screens/CleanHomeScreen.tsx b/app/src/features/home/screens/CleanHomeScreen.tsx index 21f37b99..c32f69f9 100644 --- a/app/src/features/home/screens/CleanHomeScreen.tsx +++ b/app/src/features/home/screens/CleanHomeScreen.tsx @@ -260,6 +260,24 @@ const CleanHomeScreen: React.FC = () => { /> )} + + {/* FEAT-293: standalone-practice discoverability. + Deliberately a FIXED-HEIGHT row BELOW checkInSection, not a fifth + CheckInCard: checkInSection is flex:1 and every card inside it is + also flex:1 with no ScrollView, so an extra card would squeeze all + of them. A fixed row keeps the cards equal to each other and reflows + cleanly if the three time-of-day cards are later retired. */} + navigation.navigate('PracticeLibrary')} + accessibilityRole="button" + accessibilityLabel="Practices" + accessibilityHint="Opens breathing, body scan, and Stoic practices" + testID="home-practices-entry" + > + Practices + Explore › + {/* Intro Animation Overlay */} @@ -309,6 +327,25 @@ const styles = StyleSheet.create({ flex: 1, marginTop: spacing[12], }, + // FEAT-293: fixed height, so it never competes with the flex:1 check-in cards. + practicesEntry: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing[12], + marginTop: spacing[8], + borderTopWidth: 1, + borderTopColor: colorSystem.gray[200], + }, + practicesEntryLabel: { + fontSize: typography.bodyRegular.size, + fontWeight: typography.fontWeight.medium, + color: colorSystem.base.black, + }, + practicesEntryAction: { + fontSize: typography.bodySmall.size, + color: colorSystem.gray[600], + }, checkInCard: { flex: 1, justifyContent: 'space-between', diff --git a/app/src/features/learn/tabs/PracticeTab.tsx b/app/src/features/learn/tabs/PracticeTab.tsx index 8040b86a..f219fc18 100644 --- a/app/src/features/learn/tabs/PracticeTab.tsx +++ b/app/src/features/learn/tabs/PracticeTab.tsx @@ -23,6 +23,7 @@ import { colorSystem, spacing, typography, borderRadius } from '@/core/theme'; import { useEducationStore } from '../stores/educationStore'; import type { ModuleContent, ModuleId, Practice } from '@/features/learn/types/education'; import type { RootStackParamList } from '@/core/navigation/CleanRootNavigator'; +import { resolvePracticeRoute } from '@/features/practices/catalog/practiceNavigation'; type NavigationProp = StackNavigationProp; @@ -39,69 +40,11 @@ const PracticeTab: React.FC = ({ const { incrementPracticeCount } = useEducationStore(); const handlePracticePress = (practice: Practice) => { - // Navigate to the appropriate practice screen based on type - switch (practice.type) { - case 'guided-timer': - navigation.navigate('PracticeTimer', { - practiceId: practice.id, - moduleId, - duration: practice.duration ?? 180, // Default 3 minutes - title: practice.title, - }); - break; - - case 'sorting': - // Sorting practice uses scenarios from the practice object - if (practice.scenarios && practice.scenarios.length > 0) { - navigation.navigate('SortingPractice', { - practiceId: practice.id, - moduleId, - scenarios: practice.scenarios, - }); - } else { - console.warn(`Sorting practice ${practice.id} has no scenarios`); - } - break; - - case 'body-scan': - navigation.navigate('BodyScan', { - practiceId: practice.id, - moduleId, - duration: practice.duration ?? 300, // Default 5 minutes - }); - break; - - case 'reflection': - // Reflection exercises - contemplation without breathing circle - navigation.navigate('ReflectionTimer', { - practiceId: practice.id, - moduleId, - duration: practice.duration ?? 300, // Default 5 minutes - title: practice.title, - prompt: practice.description, // Use description as reflection prompt - ...(practice.instructions && { instructions: practice.instructions }), // Full instruction steps - }); - break; - - case 'guided-body-scan': - // Self-paced resistance/tension body check - navigation.navigate('GuidedBodyScan', { - practiceId: practice.id, - moduleId, - title: practice.title, - }); - break; - - default: - console.warn(`Unknown practice type: ${practice.type}`); - // Fallback to timer screen - navigation.navigate('PracticeTimer', { - practiceId: practice.id, - moduleId, - duration: practice.duration ?? 180, - title: practice.title, - }); - } + // FEAT-293: the launch switch moved to the shared resolver so that Learn and + // the standalone Practice Library open practices through identical code. + // Behaviour here is unchanged — pinned by practiceNavigation.contract.test.ts. + const { screen, params } = resolvePracticeRoute(practice, moduleId); + navigation.navigate(screen, params as never); }; const formatDuration = (seconds: number): string => { diff --git a/app/src/features/practices/catalog/SortingPracticeRoute.tsx b/app/src/features/practices/catalog/SortingPracticeRoute.tsx new file mode 100644 index 00000000..b2888067 --- /dev/null +++ b/app/src/features/practices/catalog/SortingPracticeRoute.tsx @@ -0,0 +1,116 @@ +/** + * SORTING PRACTICE ROUTE — scenario-resolving wrapper (FEAT-293) + * + * `SortingPracticeScreen` requires a fully-loaded `scenarios` array and throws + * if one is missing at the current index. That was fine while Learn was the only + * entry point, because ModuleDetail has already loaded module content by the + * time it launches a practice. It is NOT fine for the standalone Practice + * Library (or for the `/sorting` deep link in linking.ts, which has always been + * broken for exactly this reason — a URL cannot carry a scenarios array). + * + * This wrapper resolves scenarios before rendering: it passes them straight + * through when the caller already has them (Learn — unchanged behaviour, no + * extra load, no flash of a spinner), and otherwise loads module content itself. + * Keeping the resolution OUT of SortingPracticeScreen leaves that screen's + * philosopher-signed feedback copy and hook order untouched. + */ + +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { colorSystem, spacing, typography } from '@/core/theme'; +import SortingPracticeScreen from '@/features/learn/practices/SortingPracticeScreen'; +import { loadModuleContent } from '@/core/services/moduleContent'; +import type { ModuleId, SortingScenario } from '@/features/learn/types/education'; + +interface SortingPracticeRouteProps { + practiceId: string; + moduleId: ModuleId; + /** Supplied by Learn; omitted by the Practice Library and by deep links. */ + scenarios?: SortingScenario[] | undefined; + onComplete?: (() => void) | undefined; + onBack?: (() => void) | undefined; +} + +const SortingPracticeRoute: React.FC = ({ + practiceId, + moduleId, + scenarios, + onComplete, + onBack, +}) => { + const alreadyResolved = Boolean(scenarios?.length); + const [resolved, setResolved] = useState( + alreadyResolved ? (scenarios as SortingScenario[]) : null + ); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (alreadyResolved) return; + + let cancelled = false; + (async () => { + try { + const content = await loadModuleContent(moduleId); + const practice = content.practices.find((p) => p.id === practiceId); + const loaded = practice?.scenarios ?? []; + if (cancelled) return; + if (loaded.length === 0) { + setFailed(true); + return; + } + setResolved(loaded); + } catch { + if (!cancelled) setFailed(true); + } + })(); + + return () => { + cancelled = true; + }; + }, [alreadyResolved, moduleId, practiceId]); + + if (failed) { + return ( + + + This practice couldn’t be loaded right now. + + + ); + } + + if (!resolved) { + return ( + + + + ); + } + + return ( + + ); +}; + +const styles = StyleSheet.create({ + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colorSystem.base.white, + padding: spacing[24], + }, + message: { + fontSize: typography.bodyRegular.size, + color: colorSystem.gray[600], + textAlign: 'center', + }, +}); + +export default SortingPracticeRoute; diff --git a/app/src/features/practices/catalog/practiceNavigation.ts b/app/src/features/practices/catalog/practiceNavigation.ts new file mode 100644 index 00000000..4fb4c20f --- /dev/null +++ b/app/src/features/practices/catalog/practiceNavigation.ts @@ -0,0 +1,109 @@ +/** + * PRACTICE NAVIGATION — shared launch resolver (FEAT-293) + * + * A single source of truth for "which screen does this practice open, with what + * params". Extracted from the switch that used to live inline in + * `features/learn/tabs/PracticeTab.tsx` so that Learn and the standalone + * Practice Library launch through IDENTICAL code — FEAT-293's AC3 requires no + * regression to the Learn-launched entry points, and two parallel switches is + * exactly how that regression would arrive. + * + * Deliberately PURE: it returns a route descriptor rather than calling + * `navigation.navigate` itself. That is what makes the Learn contract testable + * without mounting a navigator — before FEAT-293 there were zero tests + * referencing PracticeTab, so AC3 had no mechanical pin at all. + */ + +import type { ModuleId, Practice } from '@/features/learn/types/education'; +import type { RootStackParamList } from '@/core/navigation/CleanRootNavigator'; + +/** Default durations, preserved verbatim from the original PracticeTab switch. */ +const DEFAULT_TIMER_SECONDS = 180; +const DEFAULT_BODY_SCAN_SECONDS = 300; +const DEFAULT_REFLECTION_SECONDS = 300; + +export type PracticeRoute = { + [K in keyof RootStackParamList]: { screen: K; params: RootStackParamList[K] }; +}[ + | 'PracticeTimer' + | 'ReflectionTimer' + | 'SortingPractice' + | 'BodyScan' + | 'GuidedBodyScan' +]; + +/** + * Resolve the screen + params a practice should launch with. + * + * Never returns null: an unrecognised type falls back to the guided timer, + * matching the original switch's `default` branch. Keeping the fallback means a + * new practice type authored in module JSON degrades to a usable screen rather + * than a dead button. + */ +export function resolvePracticeRoute( + practice: Practice, + moduleId: ModuleId +): PracticeRoute { + switch (practice.type) { + case 'sorting': + // FEAT-293: `scenarios` is now OPTIONAL on the route. When Learn launches + // this it already holds the loaded module content and passes them through + // (unchanged behaviour); when the standalone library launches it, they are + // omitted and SortingPracticeScreen self-loads from module content. That + // also repairs the pre-existing `/sorting` deep link, which could never + // have supplied a scenarios array. + return { + screen: 'SortingPractice', + params: { + practiceId: practice.id, + moduleId, + ...(practice.scenarios?.length ? { scenarios: practice.scenarios } : {}), + }, + }; + + case 'body-scan': + return { + screen: 'BodyScan', + params: { + practiceId: practice.id, + moduleId, + duration: practice.duration ?? DEFAULT_BODY_SCAN_SECONDS, + }, + }; + + case 'reflection': + return { + screen: 'ReflectionTimer', + params: { + practiceId: practice.id, + moduleId, + duration: practice.duration ?? DEFAULT_REFLECTION_SECONDS, + title: practice.title, + prompt: practice.description, + ...(practice.instructions && { instructions: practice.instructions }), + }, + }; + + case 'guided-body-scan': + return { + screen: 'GuidedBodyScan', + params: { + practiceId: practice.id, + moduleId, + title: practice.title, + }, + }; + + case 'guided-timer': + default: + return { + screen: 'PracticeTimer', + params: { + practiceId: practice.id, + moduleId, + duration: practice.duration ?? DEFAULT_TIMER_SECONDS, + title: practice.title, + }, + }; + } +} diff --git a/app/src/features/practices/catalog/standalonePractices.ts b/app/src/features/practices/catalog/standalonePractices.ts new file mode 100644 index 00000000..d7f1f598 --- /dev/null +++ b/app/src/features/practices/catalog/standalonePractices.ts @@ -0,0 +1,64 @@ +/** + * STANDALONE PRACTICE CATALOG (FEAT-293) + * + * The curated set of practices promoted out of Learn onto a primary surface. + * + * Stores ONLY stable identifiers — no titles, durations, or prose. Everything + * displayable is read from the authored module JSON at render time, so this file + * cannot drift from the content the way a duplicated title or duration would. + * + * Curation is deliberately small. Twelve practices exist across the five + * modules, six of them generic reflection timers; surfacing all of them would + * make the library a list rather than an invitation. The set below covers the + * four the user story names — breathing, body scan, reflection, and especially + * the Stoic sorting drill. + * + * PRINCIPLE ATTRIBUTION IS LOAD-BEARING, not cosmetic. Each entry declares the + * principle it actually belongs to, and the library groups by that. Breathing + * and the body scan are the Aware Presence limb (embodied/metacognitive + * awareness) and must never be presented as classical Stoic technique. The + * Reserve Clause is hypexhairesis and belongs to Sphere Sovereignty — filing it + * under Aware Presence to get a tidier screen would be a mis-attribution. + */ + +import type { ModuleId } from '@/features/learn/types/education'; +import type { StoicPrinciple } from '@/features/practices/types/stoic'; + +export interface StandalonePracticeRef { + /** Drives the section heading; must be the practice's OWN principle. */ + principleKey: StoicPrinciple; + moduleId: ModuleId; + practiceId: string; +} + +/** + * The dichotomy-of-control drill, promoted to a first-class Stoic practice + * (FEAT-293 AC2). Rendered as the featured card, not as a list row. + */ +export const FEATURED_PRACTICE: StandalonePracticeRef = { + principleKey: 'sphere_sovereignty', + moduleId: 'sphere-sovereignty', + practiceId: 'control-sorting', +}; + +export const STANDALONE_PRACTICES: readonly StandalonePracticeRef[] = [ + FEATURED_PRACTICE, + // Sphere Sovereignty — the reserve clause is hypexhairesis ("fate + // permitting"), releasing attachment to outcome. Stays with its own principle. + { + principleKey: 'sphere_sovereignty', + moduleId: 'sphere-sovereignty', + practiceId: 'reserve-clause', + }, + // Aware Presence — the mindfulness limb. NOT classical Stoic exercises. + { + principleKey: 'aware_presence', + moduleId: 'aware-presence', + practiceId: 'breathing-space', + }, + { + principleKey: 'aware_presence', + moduleId: 'aware-presence', + practiceId: 'body-scan', + }, +] as const; diff --git a/app/src/features/practices/screens/PracticeLibraryScreen.tsx b/app/src/features/practices/screens/PracticeLibraryScreen.tsx new file mode 100644 index 00000000..7f59e1e9 --- /dev/null +++ b/app/src/features/practices/screens/PracticeLibraryScreen.tsx @@ -0,0 +1,345 @@ +/** + * PRACTICE LIBRARY — standalone practice discoverability (FEAT-293) + * + * The standalone practices were reachable only as modals launched from deep + * inside Learn (Learn > ModuleDetail > Practice tab > launch). The Stoic + * control-sorting drill in particular is a differentiated asset that no user was + * finding. This screen is the primary surface that fixes that. + * + * PHILOSOPHER CONSTRAINTS encoded here (non-negotiable): + * - Practices are named and grouped BY PRINCIPLE, never by mechanic. Each + * section heading and every line of principle prose is read from PRINCIPLES + * (byte-parity pinned by principles.test.ts) — no parallel copy is authored + * in this file. + * - The sorting drill may not launch context-free. Its card carries the + * Enchiridion 1 framing and a visible link back to the full principle, + * because the drill's answer key encodes a counterintuitive doctrine that + * reads as arbitrary once Module 3's prose is out of view. + * - Practices are grouped under THEIR OWN principle. Breathing and body scan + * are the Aware Presence limb; the Reserve Clause is hypexhairesis and + * belongs to Sphere Sovereignty. Filing the latter under "Aware Presence" + * would be a mis-attribution, so the grouping is derived, not hardcoded to a + * single heading. + * - NO score, percentage, tally, streak, or badge anywhere. These are + * discernment exercises; a scoreboard would convert them into performance. + * Note there is deliberately no practice-count display on this screen. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { colorSystem, spacing, typography, borderRadius } from '@/core/theme'; +import { PRINCIPLES } from '@/features/practices/shared/constants/principles'; +import { loadModuleContent } from '@/core/services/moduleContent'; +import { resolvePracticeRoute } from '@/features/practices/catalog/practiceNavigation'; +import { + STANDALONE_PRACTICES, + FEATURED_PRACTICE, +} from '@/features/practices/catalog/standalonePractices'; +import { PRACTICE_QUOTES } from '@/features/learn/practices/PracticeCompletionScreen'; +import type { ModuleId, Practice } from '@/features/learn/types/education'; +import type { StoicPrinciple } from '@/features/practices/types/stoic'; + +/** + * The framing line on the featured card. Single-sourced from the same constant + * the completion screen uses, so the drill's citation cannot drift between the + * surface that launches it and the surface that closes it. Pinned by + * practiceLibrary.contract.test.ts. + */ +const FRAMING_QUOTE = PRACTICE_QUOTES[FEATURED_PRACTICE.practiceId]; + +interface PracticeLibraryScreenProps { + onBack: () => void; + onOpenPractice: (screen: string, params: unknown) => void; + onOpenModule: (moduleId: ModuleId) => void; + testID?: string; +} + +/** A resolved catalog entry: the curated reference plus its loaded content. */ +interface ResolvedEntry { + principleKey: StoicPrinciple; + moduleId: ModuleId; + practice: Practice; +} + +const formatDuration = (seconds?: number | null): string | null => + seconds ? `${Math.round(seconds / 60)} min` : null; + +const PracticeLibraryScreen: React.FC = ({ + onBack, + onOpenPractice, + onOpenModule, + testID = 'practice-library-screen', +}) => { + const [entries, setEntries] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + // The catalog stores only stable identifiers, so titles and durations are + // read from module content rather than duplicated here (loadModuleContent + // caches, so this is cheap and cannot drift from the authored content). + const moduleIds = Array.from( + new Set(STANDALONE_PRACTICES.map((p) => p.moduleId)) + ); + const contents = await Promise.all( + moduleIds.map(async (id) => { + try { + return [id, await loadModuleContent(id)] as const; + } catch { + return [id, null] as const; + } + }) + ); + if (cancelled) return; + + const byModule = new Map(contents); + const resolved: ResolvedEntry[] = []; + for (const ref of STANDALONE_PRACTICES) { + const content = byModule.get(ref.moduleId); + const practice = content?.practices.find((p) => p.id === ref.practiceId); + if (practice) { + resolved.push({ ...ref, practice }); + } + } + setEntries(resolved); + })(); + return () => { + cancelled = true; + }; + }, []); + + /** Group by principle, preserving the canonical PRINCIPLES order. */ + const sections = useMemo(() => { + if (!entries) return []; + return PRINCIPLES.map((principle) => ({ + principle, + items: entries.filter( + (e) => + e.principleKey === principle.key && + e.practice.id !== FEATURED_PRACTICE.practiceId + ), + })).filter((s) => s.items.length > 0); + }, [entries]); + + const featured = useMemo( + () => entries?.find((e) => e.practice.id === FEATURED_PRACTICE.practiceId), + [entries] + ); + + const featuredPrinciple = PRINCIPLES.find( + (p) => p.key === FEATURED_PRACTICE.principleKey + ); + + const launch = (entry: ResolvedEntry) => { + const { screen, params } = resolvePracticeRoute(entry.practice, entry.moduleId); + onOpenPractice(screen, params); + }; + + return ( + + + + ‹ Back + + Practices + + + + {!entries ? ( + + + + ) : ( + + {/* FEATURED — the sorting drill, promoted to a first-class Stoic + practice, carrying its principle framing and citation. */} + {featured && featuredPrinciple && FRAMING_QUOTE && ( + + + {featuredPrinciple.title.toUpperCase()} + + {featured.practice.title} + + {/* Enchiridion 1 framing. IMPORTED, never retyped: this citation + already exists byte-identical as PRACTICE_QUOTES['control-sorting'], + and a hand-typed second copy is precisely how the citation drift + that FEAT-268 had to clean up recurs. */} + + “{FRAMING_QUOTE.text}” + + {FRAMING_QUOTE.author}, {FRAMING_QUOTE.source} + + + + onOpenModule(featured.moduleId)} + accessibilityRole="link" + accessibilityLabel={`Read the full principle: ${featuredPrinciple.title}`} + accessibilityHint="Opens Module 3, Sphere Sovereignty" + testID="practice-library-principle-link" + > + + Read the full principle: {featuredPrinciple.title} › + + + + launch(featured)} + accessibilityRole="button" + accessibilityLabel={`Begin ${featured.practice.title}`} + testID="practice-library-featured-start" + > + Begin + + + )} + + {sections.map(({ principle, items }) => ( + + + {principle.title.toUpperCase()} + + {items.map((entry) => { + const duration = formatDuration(entry.practice.duration); + return ( + launch(entry)} + accessibilityRole="button" + accessibilityLabel={ + duration + ? `${entry.practice.title}, ${duration}` + : entry.practice.title + } + testID={`practice-library-item-${entry.practice.id}`} + > + + {entry.practice.title} + + {duration && ( + {duration} + )} + + ); + })} + + ))} + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colorSystem.base.white }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing[16], + paddingVertical: spacing[16], + }, + backButton: { minWidth: spacing[64] }, + backText: { + fontSize: typography.bodyRegular.size, + color: colorSystem.navigation.learn, + }, + headerTitle: { + fontSize: typography.bodyLarge.size, + fontWeight: typography.fontWeight.semibold, + color: colorSystem.base.black, + }, + centered: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + scrollContent: { paddingHorizontal: spacing[24], paddingTop: spacing[8] }, + featuredCard: { + borderWidth: 1.5, + borderColor: colorSystem.navigation.learn, + borderRadius: borderRadius.xl, + padding: spacing[24], + gap: spacing[8], + marginBottom: spacing[32], + }, + principleEyebrow: { + fontSize: typography.bodySmall.size, + fontWeight: typography.fontWeight.semibold, + color: colorSystem.navigation.learn, + letterSpacing: 1, + marginBottom: spacing[4], + }, + featuredTitle: { + fontSize: typography.headline4.size, + fontWeight: typography.fontWeight.semibold, + color: colorSystem.base.black, + }, + featuredFraming: { + fontSize: typography.bodyRegular.size, + color: colorSystem.gray[700], + lineHeight: 22, + }, + citation: { + fontSize: typography.bodySmall.size, + fontStyle: 'italic', + color: colorSystem.gray[600], + }, + principleLink: { + fontSize: typography.bodySmall.size, + fontWeight: typography.fontWeight.medium, + color: colorSystem.navigation.learn, + marginTop: spacing[4], + }, + featuredButton: { + marginTop: spacing[16], + backgroundColor: colorSystem.navigation.learn, + borderRadius: borderRadius.large, + paddingVertical: spacing[16], + alignItems: 'center', + }, + featuredButtonText: { + fontSize: typography.bodyRegular.size, + fontWeight: typography.fontWeight.semibold, + color: colorSystem.base.white, + }, + section: { marginBottom: spacing[32] }, + practiceRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing[16], + borderBottomWidth: 1, + borderBottomColor: colorSystem.gray[200], + }, + practiceRowTitle: { + flex: 1, + fontSize: typography.bodyRegular.size, + color: colorSystem.base.black, + }, + practiceRowMeta: { + fontSize: typography.bodySmall.size, + color: colorSystem.gray[500], + }, +}); + +export default PracticeLibraryScreen;