From 0361792a6b39a6c0a2f2587dcabc295acc55fbdc Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 08:05:33 -0500 Subject: [PATCH 001/101] chore: update explore agent reasoning depth --- .opencode/agents/explore.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/agents/explore.md b/.opencode/agents/explore.md index 06d2cbfb..1616ecc5 100644 --- a/.opencode/agents/explore.md +++ b/.opencode/agents/explore.md @@ -73,7 +73,7 @@ permission: context7_query-docs: allow markitdown_convert_to_markdown: allow model: openai/gpt-5.6-luna -variant: max +variant: high --- You are a read-only exploration and research specialist supporting the primary engineer agent. From 2a6b93187eb44ed12d45b5ffd73201a72fe45e6c Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 08:32:57 -0500 Subject: [PATCH 002/101] feat(mobile): scaffold MVP navigation and routes --- apps/mobile/app.config.ts | 4 +- apps/mobile/app/(tabs)/_layout.tsx | 33 +++++++ apps/mobile/app/(tabs)/drill/_layout.tsx | 23 +++++ apps/mobile/app/(tabs)/drill/index.tsx | 5 + apps/mobile/app/(tabs)/field/_layout.tsx | 12 +++ apps/mobile/app/(tabs)/field/index.tsx | 5 + apps/mobile/app/(tabs)/settings/_layout.tsx | 34 +++++++ apps/mobile/app/(tabs)/settings/advanced.tsx | 10 ++ .../app/(tabs)/settings/anchor/[anchorId].tsx | 14 +++ apps/mobile/app/(tabs)/settings/anchors.tsx | 10 ++ .../settings/developer-confirmation.tsx | 10 ++ apps/mobile/app/(tabs)/settings/developer.tsx | 10 ++ apps/mobile/app/(tabs)/settings/index.tsx | 10 ++ apps/mobile/app/_layout.tsx | 39 ++++---- apps/mobile/app/index.tsx | 40 +------- .../src/features/drill/drill-screen.tsx | 10 ++ .../src/features/field/field-screen.tsx | 13 +++ .../src/features/placeholder-screen.tsx | 47 ++++++++++ .../navigation/__tests__/mobile-tabs.test.ts | 55 +++++++++++ apps/mobile/src/navigation/mobile-tabs.ts | 94 +++++++++++++++++++ .../navigation/tab-bar-visibility-context.tsx | 87 +++++++++++++++++ .../src/navigation/use-field-orientation.ts | 36 +++++++ 22 files changed, 539 insertions(+), 62 deletions(-) create mode 100644 apps/mobile/app/(tabs)/_layout.tsx create mode 100644 apps/mobile/app/(tabs)/drill/_layout.tsx create mode 100644 apps/mobile/app/(tabs)/drill/index.tsx create mode 100644 apps/mobile/app/(tabs)/field/_layout.tsx create mode 100644 apps/mobile/app/(tabs)/field/index.tsx create mode 100644 apps/mobile/app/(tabs)/settings/_layout.tsx create mode 100644 apps/mobile/app/(tabs)/settings/advanced.tsx create mode 100644 apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx create mode 100644 apps/mobile/app/(tabs)/settings/anchors.tsx create mode 100644 apps/mobile/app/(tabs)/settings/developer-confirmation.tsx create mode 100644 apps/mobile/app/(tabs)/settings/developer.tsx create mode 100644 apps/mobile/app/(tabs)/settings/index.tsx create mode 100644 apps/mobile/src/features/drill/drill-screen.tsx create mode 100644 apps/mobile/src/features/field/field-screen.tsx create mode 100644 apps/mobile/src/features/placeholder-screen.tsx create mode 100644 apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts create mode 100644 apps/mobile/src/navigation/mobile-tabs.ts create mode 100644 apps/mobile/src/navigation/tab-bar-visibility-context.tsx create mode 100644 apps/mobile/src/navigation/use-field-orientation.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 12324659..62634d56 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -19,7 +19,9 @@ const config: ExpoConfig = { slug: "eight2five", platforms: ["ios", "android"], version: "0.0.0", - orientation: "portrait", + // Field is the only route that opts into landscape; Drill and Settings + // apply portrait locks through their nested native stacks. + orientation: "default", icon: "./assets/app-icons/mobile-android-legacy-icon.png", userInterfaceStyle: "automatic", ios: { diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..7e91c867 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,33 @@ +import { NativeTabs } from "expo-router/unstable-native-tabs"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { MOBILE_TABS } from "../../src/navigation/mobile-tabs"; +import { useTabBarVisibility } from "../../src/navigation/tab-bar-visibility-context"; + +export default function MobileTabsLayout() { + const theme = useEight2FiveTheme(); + const { drillFeaturesEnabled, nativeTabBarHidden, nativeTabsRevision } = + useTabBarVisibility(); + + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/drill/_layout.tsx b/apps/mobile/app/(tabs)/drill/_layout.tsx new file mode 100644 index 00000000..5bdd590c --- /dev/null +++ b/apps/mobile/app/(tabs)/drill/_layout.tsx @@ -0,0 +1,23 @@ +import { Stack } from "expo-router"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; + +export default function DrillLayout() { + const theme = useEight2FiveTheme(); + + return ( + + + + ); +} diff --git a/apps/mobile/app/(tabs)/drill/index.tsx b/apps/mobile/app/(tabs)/drill/index.tsx new file mode 100644 index 00000000..eab81152 --- /dev/null +++ b/apps/mobile/app/(tabs)/drill/index.tsx @@ -0,0 +1,5 @@ +import { DrillScreen } from "../../../src/features/drill/drill-screen"; + +export default function DrillRoute() { + return ; +} diff --git a/apps/mobile/app/(tabs)/field/_layout.tsx b/apps/mobile/app/(tabs)/field/_layout.tsx new file mode 100644 index 00000000..64feb7c4 --- /dev/null +++ b/apps/mobile/app/(tabs)/field/_layout.tsx @@ -0,0 +1,12 @@ +import { Stack } from "expo-router"; + +export default function FieldLayout() { + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/field/index.tsx b/apps/mobile/app/(tabs)/field/index.tsx new file mode 100644 index 00000000..836d78bc --- /dev/null +++ b/apps/mobile/app/(tabs)/field/index.tsx @@ -0,0 +1,5 @@ +import { FieldScreen } from "../../../src/features/field/field-screen"; + +export default function FieldRoute() { + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/_layout.tsx b/apps/mobile/app/(tabs)/settings/_layout.tsx new file mode 100644 index 00000000..d35451d9 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/_layout.tsx @@ -0,0 +1,34 @@ +import { Stack } from "expo-router"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; + +export default function SettingsLayout() { + const theme = useEight2FiveTheme(); + + return ( + + + + + + + + + ); +} diff --git a/apps/mobile/app/(tabs)/settings/advanced.tsx b/apps/mobile/app/(tabs)/settings/advanced.tsx new file mode 100644 index 00000000..e52c2f2e --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/advanced.tsx @@ -0,0 +1,10 @@ +import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; + +export default function AdvancedSettingsRoute() { + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx b/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx new file mode 100644 index 00000000..bc9c16f4 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx @@ -0,0 +1,14 @@ +import { useLocalSearchParams } from "expo-router"; + +import { PlaceholderScreen } from "../../../../src/features/placeholder-screen"; + +export default function AnchorRoute() { + const { anchorId } = useLocalSearchParams<{ anchorId: string }>(); + + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/settings/anchors.tsx b/apps/mobile/app/(tabs)/settings/anchors.tsx new file mode 100644 index 00000000..1e322ecd --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/anchors.tsx @@ -0,0 +1,10 @@ +import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; + +export default function AnchorsRoute() { + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx b/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx new file mode 100644 index 00000000..b15be7c7 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx @@ -0,0 +1,10 @@ +import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; + +export default function DeveloperConfirmationRoute() { + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/settings/developer.tsx b/apps/mobile/app/(tabs)/settings/developer.tsx new file mode 100644 index 00000000..66d49165 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/developer.tsx @@ -0,0 +1,10 @@ +import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; + +export default function DeveloperSettingsRoute() { + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/settings/index.tsx b/apps/mobile/app/(tabs)/settings/index.tsx new file mode 100644 index 00000000..81ed2e5a --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/index.tsx @@ -0,0 +1,10 @@ +import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; + +export default function SettingsRoute() { + return ( + + ); +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index b08450ba..83c1a684 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,12 +1,12 @@ import React from "react"; import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; +import { StatusBar } from "expo-status-bar"; +import { SafeAreaProvider } from "react-native-safe-area-context"; import { GluestackUIProvider } from "@eight2five/ui/components/gluestack-ui-provider"; -import { - eight2FiveFonts, - useEight2FiveFonts, - useEight2FiveTheme, -} from "@eight2five/ui/theme"; +import { useEight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { TabBarVisibilityProvider } from "../src/navigation/tab-bar-visibility-context"; import "../global.css"; @@ -27,24 +27,17 @@ export default function MobileRootLayout() { return ( - - - + + + + + + ); } diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 8c5b48c5..cc483ae8 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,41 +1,5 @@ -import { StatusBar } from "expo-status-bar"; -import { Text, View } from "react-native"; -import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; +import { Redirect } from "expo-router"; export default function MobileHomeRoute() { - const theme = useEight2FiveTheme(); - - return ( - - - Eight2Five - - - Marching band positioning tools - - - - ); + return ; } diff --git a/apps/mobile/src/features/drill/drill-screen.tsx b/apps/mobile/src/features/drill/drill-screen.tsx new file mode 100644 index 00000000..d6558c6d --- /dev/null +++ b/apps/mobile/src/features/drill/drill-screen.tsx @@ -0,0 +1,10 @@ +import { PlaceholderScreen } from "../placeholder-screen"; + +export function DrillScreen() { + return ( + + ); +} diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx new file mode 100644 index 00000000..c0ba50bf --- /dev/null +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -0,0 +1,13 @@ +import { PlaceholderScreen } from "../placeholder-screen"; +import { useFieldOrientation } from "../../navigation/use-field-orientation"; + +export function FieldScreen() { + useFieldOrientation(); + + return ( + + ); +} diff --git a/apps/mobile/src/features/placeholder-screen.tsx b/apps/mobile/src/features/placeholder-screen.tsx new file mode 100644 index 00000000..8c8011ac --- /dev/null +++ b/apps/mobile/src/features/placeholder-screen.tsx @@ -0,0 +1,47 @@ +import { Center } from "@eight2five/ui/components/center"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +export function PlaceholderScreen({ + title, + description, +}: { + title: string; + description: string; +}) { + const theme = useEight2FiveTheme(); + + return ( +
+ + + {title} + + + {description} + + +
+ ); +} diff --git a/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts new file mode 100644 index 00000000..2db39ec0 --- /dev/null +++ b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts @@ -0,0 +1,55 @@ +import { + INITIAL_MOBILE_TAB_NAVIGATION_STATE, + MOBILE_TABS, + reduceMobileTabNavigationState, + shouldHideNativeTabBar, +} from "../mobile-tabs"; + +describe("mobile native tab navigation", () => { + test("keeps Field first and exposes the expected tabs", () => { + expect(MOBILE_TABS.map(({ name, label }) => ({ name, label }))).toEqual([ + { name: "field", label: "Field" }, + { name: "drill", label: "Drill" }, + { name: "settings", label: "Settings" }, + ]); + }); + + test("hides the entire tab bar only for focused landscape Field", () => { + expect( + shouldHideNativeTabBar({ fieldFocused: true, fieldLandscape: true }), + ).toBe(true); + expect( + shouldHideNativeTabBar({ fieldFocused: true, fieldLandscape: false }), + ).toBe(false); + expect( + shouldHideNativeTabBar({ fieldFocused: false, fieldLandscape: true }), + ).toBe(false); + }); + + test("remounts native tabs exactly once per drill-feature change", () => { + const disabled = reduceMobileTabNavigationState( + INITIAL_MOBILE_TAB_NAVIGATION_STATE, + { type: "drill-features-reconfigured", enabled: false }, + ); + expect(disabled).toMatchObject({ + drillFeaturesEnabled: false, + nativeTabsRevision: 1, + }); + + const unchanged = reduceMobileTabNavigationState(disabled, { + type: "drill-features-reconfigured", + enabled: false, + }); + expect(unchanged).toBe(disabled); + + expect( + reduceMobileTabNavigationState(unchanged, { + type: "drill-features-reconfigured", + enabled: true, + }), + ).toMatchObject({ + drillFeaturesEnabled: true, + nativeTabsRevision: 2, + }); + }); +}); diff --git a/apps/mobile/src/navigation/mobile-tabs.ts b/apps/mobile/src/navigation/mobile-tabs.ts new file mode 100644 index 00000000..a8500b57 --- /dev/null +++ b/apps/mobile/src/navigation/mobile-tabs.ts @@ -0,0 +1,94 @@ +import type { NativeTabsTriggerIconProps } from "expo-router/unstable-native-tabs"; + +export type MobileTabName = "field" | "drill" | "settings"; + +export interface MobileTabConfig { + name: MobileTabName; + label: string; + icon: NativeTabsTriggerIconProps; +} + +export const MOBILE_TABS = [ + { + name: "field", + label: "Field", + icon: { + sf: { default: "map", selected: "map.fill" }, + md: "map", + }, + }, + { + name: "drill", + label: "Drill", + icon: { + sf: { + default: "list.bullet.rectangle", + selected: "list.bullet.rectangle.fill", + }, + md: "format_list_numbered", + }, + }, + { + name: "settings", + label: "Settings", + icon: { + sf: { default: "gearshape", selected: "gearshape.fill" }, + md: "settings", + }, + }, +] as const satisfies readonly MobileTabConfig[]; + +export function shouldHideNativeTabBar({ + fieldFocused, + fieldLandscape, +}: Pick): boolean { + return fieldFocused && fieldLandscape; +} + +export interface MobileTabNavigationState { + fieldFocused: boolean; + fieldLandscape: boolean; + drillFeaturesEnabled: boolean; + nativeTabsRevision: number; +} + +export const INITIAL_MOBILE_TAB_NAVIGATION_STATE: MobileTabNavigationState = { + fieldFocused: false, + fieldLandscape: false, + drillFeaturesEnabled: true, + nativeTabsRevision: 0, +}; + +export type MobileTabNavigationAction = + | { + type: "field-presentation-changed"; + fieldFocused: boolean; + fieldLandscape: boolean; + } + | { type: "drill-features-reconfigured"; enabled: boolean }; + +export function reduceMobileTabNavigationState( + state: MobileTabNavigationState, + action: MobileTabNavigationAction, +): MobileTabNavigationState { + if (action.type === "field-presentation-changed") { + if ( + state.fieldFocused === action.fieldFocused && + state.fieldLandscape === action.fieldLandscape + ) { + return state; + } + return { + ...state, + fieldFocused: action.fieldFocused, + fieldLandscape: action.fieldLandscape, + }; + } + + if (state.drillFeaturesEnabled === action.enabled) return state; + return { + ...state, + drillFeaturesEnabled: action.enabled, + nativeTabsRevision: state.nativeTabsRevision + 1, + }; +} diff --git a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx new file mode 100644 index 00000000..bf019f81 --- /dev/null +++ b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { useRouter } from "expo-router"; + +import { + INITIAL_MOBILE_TAB_NAVIGATION_STATE, + reduceMobileTabNavigationState, + shouldHideNativeTabBar, + type MobileTabNavigationState, +} from "./mobile-tabs"; + +interface FieldPresentation { + focused: boolean; + landscape: boolean; +} + +interface TabBarVisibilityContextValue extends MobileTabNavigationState { + nativeTabBarHidden: boolean; + setFieldPresentation(presentation: FieldPresentation): void; + /** + * Call only after the setting has been persisted. This safely leaves Drill, + * then changes tab membership and remounts the native navigator exactly once. + */ + reconfigureDrillFeatures(enabled: boolean): void; +} + +const TabBarVisibilityContext = React.createContext< + TabBarVisibilityContextValue | undefined +>(undefined); + +export function TabBarVisibilityProvider({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const [state, dispatch] = React.useReducer( + reduceMobileTabNavigationState, + INITIAL_MOBILE_TAB_NAVIGATION_STATE, + ); + + const setFieldPresentation = React.useCallback( + ({ focused, landscape }: FieldPresentation) => { + dispatch({ + type: "field-presentation-changed", + fieldFocused: focused, + fieldLandscape: landscape, + }); + }, + [], + ); + + const reconfigureDrillFeatures = React.useCallback( + (enabled: boolean) => { + if (state.drillFeaturesEnabled === enabled) return; + + router.replace("/(tabs)/field"); + dispatch({ type: "drill-features-reconfigured", enabled }); + }, + [router, state.drillFeaturesEnabled], + ); + + const value = React.useMemo( + () => ({ + ...state, + nativeTabBarHidden: shouldHideNativeTabBar(state), + setFieldPresentation, + reconfigureDrillFeatures, + }), + [reconfigureDrillFeatures, setFieldPresentation, state], + ); + + return ( + + {children} + + ); +} + +export function useTabBarVisibility(): TabBarVisibilityContextValue { + const value = React.useContext(TabBarVisibilityContext); + if (!value) { + throw new Error( + "useTabBarVisibility must be used inside TabBarVisibilityProvider.", + ); + } + return value; +} diff --git a/apps/mobile/src/navigation/use-field-orientation.ts b/apps/mobile/src/navigation/use-field-orientation.ts new file mode 100644 index 00000000..143a2bd7 --- /dev/null +++ b/apps/mobile/src/navigation/use-field-orientation.ts @@ -0,0 +1,36 @@ +import React from "react"; +import { useFocusEffect } from "expo-router"; +import { useWindowDimensions } from "react-native"; + +import { useTabBarVisibility } from "./tab-bar-visibility-context"; + +export interface FieldOrientationState { + focused: boolean; + landscape: boolean; +} + +/** Bridges Field focus and physical viewport orientation to the native tabs. */ +export function useFieldOrientation(): FieldOrientationState { + const [focused, setFocused] = React.useState(false); + const { width, height } = useWindowDimensions(); + const landscape = width > height; + const { setFieldPresentation } = useTabBarVisibility(); + + useFocusEffect( + React.useCallback(() => { + setFocused(true); + return () => setFocused(false); + }, []), + ); + + React.useEffect(() => { + setFieldPresentation({ focused, landscape }); + }, [focused, landscape, setFieldPresentation]); + + React.useEffect( + () => () => setFieldPresentation({ focused: false, landscape: false }), + [setFieldPresentation], + ); + + return { focused, landscape }; +} From 65a75acea62c3218bb3e262dfe75d1c5ca78b38a Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 20:58:13 -0500 Subject: [PATCH 003/101] feat(mobile): add marching field and drill domain --- packages/mobile/package.json | 6 +- .../src/drill/__tests__/analysis.test.ts | 126 +++++ .../src/drill/__tests__/terminology.test.ts | 26 ++ packages/mobile/src/drill/analysis.ts | 122 +++++ packages/mobile/src/drill/index.ts | 3 + packages/mobile/src/drill/terminology.ts | 31 ++ packages/mobile/src/drill/types.ts | 23 + .../src/field/__tests__/guidance.test.ts | 39 ++ .../src/field/__tests__/marching.test.ts | 201 ++++++++ .../src/field/__tests__/template.test.ts | 71 +++ .../mobile/src/field/__tests__/units.test.ts | 43 ++ packages/mobile/src/field/guidance.ts | 62 +++ packages/mobile/src/field/index.ts | 5 + packages/mobile/src/field/marching.ts | 435 ++++++++++++++++++ packages/mobile/src/field/template.ts | 252 ++++++++++ packages/mobile/src/field/types.ts | 79 ++++ packages/mobile/src/field/units.ts | 89 ++++ packages/mobile/src/index.ts | 34 ++ 18 files changed, 1645 insertions(+), 2 deletions(-) create mode 100644 packages/mobile/src/drill/__tests__/analysis.test.ts create mode 100644 packages/mobile/src/drill/__tests__/terminology.test.ts create mode 100644 packages/mobile/src/drill/analysis.ts create mode 100644 packages/mobile/src/drill/index.ts create mode 100644 packages/mobile/src/drill/terminology.ts create mode 100644 packages/mobile/src/drill/types.ts create mode 100644 packages/mobile/src/field/__tests__/guidance.test.ts create mode 100644 packages/mobile/src/field/__tests__/marching.test.ts create mode 100644 packages/mobile/src/field/__tests__/template.test.ts create mode 100644 packages/mobile/src/field/__tests__/units.test.ts create mode 100644 packages/mobile/src/field/guidance.ts create mode 100644 packages/mobile/src/field/index.ts create mode 100644 packages/mobile/src/field/marching.ts create mode 100644 packages/mobile/src/field/template.ts create mode 100644 packages/mobile/src/field/types.ts create mode 100644 packages/mobile/src/field/units.ts diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 4b865b10..914d10ed 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -6,7 +6,9 @@ "types": "src/index.ts", "exports": { ".": "./src/index.ts", - "./pans-manager": "./src/pans-manager/index.ts" + "./pans-manager": "./src/pans-manager/index.ts", + "./field": "./src/field/index.ts", + "./drill": "./src/drill/index.ts" }, "files": [ "src" @@ -15,7 +17,7 @@ "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", "type-check": "tsc --noEmit -p tsconfig.json", - "test": "jest src/pans-manager --watchAll=false --passWithNoTests --runInBand" + "test": "jest src --watchAll=false --passWithNoTests --runInBand" }, "peerDependencies": { "react": "19.2.3", diff --git a/packages/mobile/src/drill/__tests__/analysis.test.ts b/packages/mobile/src/drill/__tests__/analysis.test.ts new file mode 100644 index 00000000..3df042ee --- /dev/null +++ b/packages/mobile/src/drill/__tests__/analysis.test.ts @@ -0,0 +1,126 @@ +import { + analyzeDrillTransition, + analyzeTransition, + type DrillPage, +} from "../index"; +import { standardStepsToMeters, yardsToMeters } from "../../field"; + +function page( + id: string, + xYards: number, + countsFromPrevious: number, + yMeters = 0, +): DrillPage { + return { + id, + drillId: "drill-1", + ordinal: Number(id), + label: id, + countsFromPrevious, + position: { xMeters: yardsToMeters(xYards), yMeters }, + }; +} + +describe("drill transition analysis", () => { + test("omits derived rates for the first page", () => { + expect(analyzeDrillTransition(undefined, page("1", 10, 0))).toEqual({ + distanceSteps: 0, + isHalt: false, + yardLineCrossingCounts: [], + }); + }); + + test("omits step size and crossing counts for zero counts", () => { + const analysis = analyzeDrillTransition(page("1", 10, 0), page("2", 20, 0)); + expect(analysis.distanceSteps).toBe(16); + expect(analysis.stepSizeToFive).toBeUndefined(); + expect(analysis.yardLineCrossingCounts).toEqual([]); + }); + + test("recognizes a same-position positive-count halt", () => { + const analysis = analyzeDrillTransition( + page("1", 40, 0), + page("2", 40, 16), + ); + expect(analysis).toMatchObject({ distanceSteps: 0, isHalt: true }); + expect(analysis.stepSizeToFive).toBeUndefined(); + }); + + test.each([ + [standardStepsToMeters(8), 8, 8], + [standardStepsToMeters(4.5), 8, 14.25], + [standardStepsToMeters(16), 13, 6.5], + ])( + "derives %s meters over %s counts as %s-to-5", + (distance, counts, expected) => { + expect( + analyzeTransition( + { xMeters: 0, yMeters: 0 }, + { xMeters: distance, yMeters: 0 }, + counts, + ).stepSizeToFive, + ).toBe(expected); + }, + ); + + test("returns the transition count at one and multiple line crossings", () => { + expect( + analyzeTransition( + { xMeters: yardsToMeters(10), yMeters: 0 }, + { xMeters: yardsToMeters(20), yMeters: 0 }, + 8, + ).yardLineCrossingCounts, + ).toEqual([4]); + expect( + analyzeTransition( + { xMeters: yardsToMeters(10), yMeters: 0 }, + { xMeters: yardsToMeters(25), yMeters: 0 }, + 16, + ).yardLineCrossingCounts, + ).toEqual([5.333333, 10.666667]); + expect( + analyzeTransition( + { xMeters: yardsToMeters(12.5), yMeters: 0 }, + { xMeters: yardsToMeters(22.5), yMeters: 0 }, + 16, + ).yardLineCrossingCounts, + ).toEqual([4, 12]); + }); + + test("returns crossing counts in time order for reverse movement", () => { + expect( + analyzeTransition( + { xMeters: yardsToMeters(25), yMeters: 0 }, + { xMeters: yardsToMeters(10), yMeters: 0 }, + 16, + ).yardLineCrossingCounts, + ).toEqual([5.333333, 10.666667]); + }); + + test("excludes exact start and end yard lines", () => { + expect( + analyzeTransition( + { xMeters: yardsToMeters(10), yMeters: 0 }, + { xMeters: yardsToMeters(15), yMeters: 0 }, + 8, + ).yardLineCrossingCounts, + ).toEqual([]); + }); + + test("cleans floating-point values near integer and half counts", () => { + expect( + analyzeTransition( + { xMeters: yardsToMeters(10), yMeters: 0 }, + { xMeters: yardsToMeters(20) + 1e-12, yMeters: 0 }, + 8, + ).yardLineCrossingCounts, + ).toEqual([4]); + }); + + test("keeps derived values off persisted page records", () => { + const current = page("2", 20, 8); + analyzeDrillTransition(page("1", 10, 0), current); + expect(current).not.toHaveProperty("stepSizeToFive"); + expect(current).not.toHaveProperty("yardLineCrossingCounts"); + }); +}); diff --git a/packages/mobile/src/drill/__tests__/terminology.test.ts b/packages/mobile/src/drill/__tests__/terminology.test.ts new file mode 100644 index 00000000..6a88cbf6 --- /dev/null +++ b/packages/mobile/src/drill/__tests__/terminology.test.ts @@ -0,0 +1,26 @@ +import { getDrillTerms } from "../index"; + +describe("drill terminology", () => { + test("centralizes Page labels", () => { + expect(getDrillTerms("pages")).toEqual({ + singular: "Page", + plural: "Pages", + lowercaseSingular: "page", + lowercasePlural: "pages", + }); + }); + + test("centralizes Set labels", () => { + expect(getDrillTerms("sets")).toEqual({ + singular: "Set", + plural: "Sets", + lowercaseSingular: "set", + lowercasePlural: "sets", + }); + }); + + test("reuses immutable canonical term objects", () => { + expect(getDrillTerms("pages")).toBe(getDrillTerms("pages")); + expect(Object.isFrozen(getDrillTerms("pages"))).toBe(true); + }); +}); diff --git a/packages/mobile/src/drill/analysis.ts b/packages/mobile/src/drill/analysis.ts new file mode 100644 index 00000000..9b9daf87 --- /dev/null +++ b/packages/mobile/src/drill/analysis.ts @@ -0,0 +1,122 @@ +import { assertFiniteFieldPoint, type FieldPoint } from "../field/types"; +import { + metersToStandardSteps, + STANDARD_STEPS_PER_FIVE_YARDS, +} from "../field/units"; +import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "../field/template"; +import type { DrillPage } from "./types"; + +const POSITION_EPSILON_METERS = 1e-9; +const NUMBER_EPSILON = 1e-8; + +export interface TransitionAnalysis { + readonly distanceSteps: number; + readonly stepSizeToFive?: number; + readonly isHalt: boolean; + readonly yardLineCrossingCounts: readonly number[]; +} + +function assertCounts(counts: number): void { + if (!Number.isFinite(counts) || counts < 0) { + throw new RangeError( + "Transition counts must be a finite non-negative number.", + ); + } +} + +function cleanNearHalf(value: number): number { + const nearestHalf = Math.round(value * 2) / 2; + if (Math.abs(value - nearestHalf) <= NUMBER_EPSILON) return nearestHalf; + return Number(value.toFixed(6)); +} + +function roundToQuarter(value: number): number { + return Number((Math.round(value * 4) / 4).toFixed(2)); +} + +function isSamePoint(start: FieldPoint, end: FieldPoint): boolean { + return ( + Math.abs(start.xMeters - end.xMeters) <= POSITION_EPSILON_METERS && + Math.abs(start.yMeters - end.yMeters) <= POSITION_EPSILON_METERS + ); +} + +function crossingCounts( + start: FieldPoint, + end: FieldPoint, + counts: number, +): readonly number[] { + const xDelta = end.xMeters - start.xMeters; + if (Math.abs(xDelta) <= POSITION_EPSILON_METERS || counts === 0) return []; + + const crossings = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE.allFiveYardLines + .map((line) => (line.coordinateMeters - start.xMeters) / xDelta) + .filter( + (progress) => progress > NUMBER_EPSILON && progress < 1 - NUMBER_EPSILON, + ) + .sort((left, right) => left - right) + .map((progress) => cleanNearHalf(progress * counts)); + + return Object.freeze(crossings); +} + +/** + * Derives all transition metrics from canonical points and counts. These + * values are intentionally never fields on DrillPage or persisted records. + */ +export function analyzeTransition( + previousPosition: FieldPoint | null | undefined, + currentPosition: FieldPoint, + countsFromPrevious: number, +): TransitionAnalysis { + assertFiniteFieldPoint(currentPosition, "Current position"); + assertCounts(countsFromPrevious); + + if (!previousPosition) { + return Object.freeze({ + distanceSteps: 0, + isHalt: false, + yardLineCrossingCounts: Object.freeze([]), + }); + } + + assertFiniteFieldPoint(previousPosition, "Previous position"); + const distanceSteps = metersToStandardSteps( + Math.hypot( + currentPosition.xMeters - previousPosition.xMeters, + currentPosition.yMeters - previousPosition.yMeters, + ), + ); + const isHalt = + countsFromPrevious > 0 && isSamePoint(previousPosition, currentPosition); + const stepSizeToFive = + countsFromPrevious > 0 && distanceSteps > POSITION_EPSILON_METERS + ? roundToQuarter( + (countsFromPrevious * STANDARD_STEPS_PER_FIVE_YARDS) / distanceSteps, + ) + : undefined; + + return Object.freeze({ + distanceSteps: cleanNearHalf(distanceSteps), + ...(stepSizeToFive === undefined ? {} : { stepSizeToFive }), + isHalt, + yardLineCrossingCounts: crossingCounts( + previousPosition, + currentPosition, + countsFromPrevious, + ), + }); +} + +export function analyzeDrillTransition( + previousPage: DrillPage | null | undefined, + currentPage: DrillPage, +): TransitionAnalysis { + return analyzeTransition( + previousPage?.position, + currentPage.position, + currentPage.countsFromPrevious, + ); +} + +export const calculateTransitionAnalysis = analyzeTransition; diff --git a/packages/mobile/src/drill/index.ts b/packages/mobile/src/drill/index.ts new file mode 100644 index 00000000..3a3f92a2 --- /dev/null +++ b/packages/mobile/src/drill/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./terminology"; +export * from "./analysis"; diff --git a/packages/mobile/src/drill/terminology.ts b/packages/mobile/src/drill/terminology.ts new file mode 100644 index 00000000..3fa452b5 --- /dev/null +++ b/packages/mobile/src/drill/terminology.ts @@ -0,0 +1,31 @@ +/** The user-facing noun chosen for a drill's ordered pages. */ +export type DrillTerminology = "pages" | "sets"; + +/** Literal labels returned by the centralized terminology helper. */ +export interface DrillTerms { + readonly singular: "Page" | "Set"; + readonly plural: "Pages" | "Sets"; + readonly lowercaseSingular: "page" | "set"; + readonly lowercasePlural: "pages" | "sets"; +} + +const PAGE_TERMS: DrillTerms = Object.freeze({ + singular: "Page", + plural: "Pages", + lowercaseSingular: "page", + lowercasePlural: "pages", +}); + +const SET_TERMS: DrillTerms = Object.freeze({ + singular: "Set", + plural: "Sets", + lowercaseSingular: "set", + lowercasePlural: "sets", +}); + +/** Keeps terminology selection in one place so UI labels cannot drift. */ +export function getDrillTerms(terminology: DrillTerminology): DrillTerms { + if (terminology === "pages") return PAGE_TERMS; + if (terminology === "sets") return SET_TERMS; + throw new RangeError(`Unknown drill terminology: ${String(terminology)}.`); +} diff --git a/packages/mobile/src/drill/types.ts b/packages/mobile/src/drill/types.ts new file mode 100644 index 00000000..15f4847b --- /dev/null +++ b/packages/mobile/src/drill/types.ts @@ -0,0 +1,23 @@ +import type { FieldPoint } from "../field"; + +/** + * A drill is deliberately performer-agnostic in this phase. Performer + * identity, assignment, and per-performer positions belong to a later domain + * layer and must not leak into these shared page records. + */ +export interface Drill { + readonly id: string; + readonly name: string; + readonly createdAt: number; + readonly updatedAt: number; +} + +/** One performer-independent target page in a drill. */ +export interface DrillPage { + readonly id: string; + readonly drillId: string; + readonly ordinal: number; + readonly label: string; + readonly countsFromPrevious: number; + readonly position: FieldPoint; +} diff --git a/packages/mobile/src/field/__tests__/guidance.test.ts b/packages/mobile/src/field/__tests__/guidance.test.ts new file mode 100644 index 00000000..914bfef5 --- /dev/null +++ b/packages/mobile/src/field/__tests__/guidance.test.ts @@ -0,0 +1,39 @@ +import { calculateFieldGuidance, standardStepsToMeters } from "../index"; + +describe("field guidance", () => { + test("returns signed field-relative axis guidance and straight-line distance", () => { + const guidance = calculateFieldGuidance( + { xMeters: standardStepsToMeters(10), yMeters: standardStepsToMeters(5) }, + { + xMeters: standardStepsToMeters(2.5), + yMeters: standardStepsToMeters(8), + }, + ); + expect(guidance.xDisplacementSteps).toBeCloseTo(-7.5); + expect(guidance.yDisplacementSteps).toBeCloseTo(3); + expect(guidance.distanceSteps).toBeCloseTo(Math.hypot(7.5, 3)); + expect(guidance.xLabel).toBe("7.5 steps toward Side 1"); + expect(guidance.yLabel).toBe("3 steps toward the back sideline"); + }); + + test("uses front-sideline wording for negative Y and no phone heading", () => { + const guidance = calculateFieldGuidance( + { xMeters: 0, yMeters: standardStepsToMeters(3) }, + { xMeters: 0, yMeters: 0 }, + ); + expect(guidance.yDisplacementSteps).toBe(-3); + expect(guidance.yLabel).toBe("3 steps toward the front sideline"); + expect(guidance).not.toHaveProperty("heading"); + expect(guidance).not.toHaveProperty("bearing"); + }); + + test("returns zero-axis guidance without inventing a direction", () => { + const guidance = calculateFieldGuidance( + { xMeters: 0, yMeters: 0 }, + { xMeters: 0, yMeters: 0 }, + ); + expect(guidance.distanceSteps).toBe(0); + expect(guidance.xLabel).toBe("0 steps"); + expect(guidance.yLabel).toBe("0 steps"); + }); +}); diff --git a/packages/mobile/src/field/__tests__/marching.test.ts b/packages/mobile/src/field/__tests__/marching.test.ts new file mode 100644 index 00000000..da4aaafe --- /dev/null +++ b/packages/mobile/src/field/__tests__/marching.test.ts @@ -0,0 +1,201 @@ +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + fieldPointToMarchingCoordinate, + formatMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, + marchingCoordinateToFieldPoint, + standardStepsToMeters, + yardsToMeters, +} from "../index"; + +const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + +describe("marching coordinate conversion", () => { + test("formats exact side examples", () => { + expect( + formatMarchingSide( + fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(60), + yMeters: field.bounds.minYMeters, + }).side, + ), + ).toBe("Side 2: On 40 yd ln"); + expect( + formatMarchingSide( + fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(35) + standardStepsToMeters(2), + yMeters: field.bounds.minYMeters, + }).side, + ), + ).toBe("Side 1: 2 Steps inside 35 yd ln"); + expect( + formatMarchingSide( + fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(60) + standardStepsToMeters(1.25), + yMeters: field.bounds.minYMeters, + }).side, + ), + ).toBe("Side 2: 1.25 Steps outside 40 yd ln"); + expect( + formatMarchingSide( + fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(50), + yMeters: field.bounds.minYMeters, + }).side, + ), + ).toBe("On 50 yd ln"); + }); + + test("formats exact front/back examples", () => { + const examples = [ + [field.bounds.minYMeters, "On Front Sideline"], + [standardStepsToMeters(8), "8 Steps behind Front Sideline"], + [ + field.frontHashLine.coordinateMeters - standardStepsToMeters(12), + "12 Steps in front of HS FH", + ], + [field.frontHashLine.coordinateMeters, "On HS FH"], + [ + field.frontHashLine.coordinateMeters + standardStepsToMeters(4), + "4 Steps behind HS FH", + ], + [ + field.backHashLine.coordinateMeters - standardStepsToMeters(3.5), + "3.5 Steps in front of HS BH", + ], + [field.bounds.maxYMeters, "On Back Sideline"], + ] as const; + + for (const [yMeters, expected] of examples) { + expect( + formatMarchingFrontBack( + fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(50), + yMeters, + }).frontBack, + ), + ).toBe(expected); + } + }); + + test("keeps canonical fractional values while formatting quarter steps", () => { + const coordinate = fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(35) + standardStepsToMeters(1.249999999), + yMeters: + field.frontHashLine.coordinateMeters + + standardStepsToMeters(2.500000001), + }); + expect(coordinate.side.offsetSteps).toBeCloseTo(1.249999999); + expect(formatMarchingSide(coordinate.side)).toBe( + "Side 1: 1.25 Steps inside 35 yd ln", + ); + expect(formatMarchingFrontBack(coordinate.frontBack)).toBe( + "2.5 Steps behind HS FH", + ); + }); + + test("uses centerward references for exact halfway ties", () => { + const sideTie = fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(32.5), + yMeters: field.bounds.minYMeters, + }); + expect(sideTie.side).toMatchObject({ + side: 1, + yardLine: 35, + relation: "outside", + }); + + const lateralTie = fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(50), + yMeters: + (field.frontHashLine.coordinateMeters + + field.backHashLine.coordinateMeters) / + 2, + }); + expect(lateralTie.frontBack.reference).toBe("front-hash"); + }); + + test("uses a side and outside terminology when the 50 is nearest", () => { + const coordinate = fieldPointToMarchingCoordinate({ + xMeters: yardsToMeters(50) - standardStepsToMeters(1.5), + yMeters: 0, + }); + expect(formatMarchingSide(coordinate.side)).toBe( + "Side 1: 1.5 Steps outside 50 yd ln", + ); + expect(marchingCoordinateToFieldPoint(coordinate).xMeters).toBeCloseTo( + yardsToMeters(50) - standardStepsToMeters(1.5), + ); + }); + + test("marks out-of-bounds points explicitly while retaining nearest references", () => { + const coordinate = fieldPointToMarchingCoordinate({ + xMeters: -standardStepsToMeters(2), + yMeters: field.bounds.maxYMeters + standardStepsToMeters(1.25), + }); + expect(formatMarchingCoordinate(coordinate)).toBe( + "Out of Bounds — Side 1: 2 Steps outside Goal Line; 1.25 Steps behind Back Sideline", + ); + expect(coordinate.outOfBounds).toEqual(["goal-to-goal", "front-back"]); + }); + + test("round trips ordinary finite points without display quantization", () => { + const points = [ + { xMeters: yardsToMeters(0), yMeters: 0 }, + { xMeters: yardsToMeters(12.345678), yMeters: 1.234567 }, + { + xMeters: yardsToMeters(50), + yMeters: field.frontHashLine.coordinateMeters, + }, + { xMeters: yardsToMeters(87.654321), yMeters: 42.123456 }, + { xMeters: field.goalToGoalMeters, yMeters: field.widthMeters }, + ]; + for (const point of points) { + const roundTrip = marchingCoordinateToFieldPoint( + fieldPointToMarchingCoordinate(point), + ); + expect(roundTrip.xMeters).toBeCloseTo(point.xMeters, 10); + expect(roundTrip.yMeters).toBeCloseTo(point.yMeters, 10); + } + }); + + test("rejects non-finite points", () => { + expect(() => + fieldPointToMarchingCoordinate({ xMeters: Number.NaN, yMeters: 0 }), + ).toThrow("xMeters"); + expect(() => + marchingCoordinateToFieldPoint({ + side: { + side: 1, + yardLine: 35, + offsetSteps: -1, + relation: "inside", + }, + frontBack: { + reference: "front-sideline", + offsetSteps: 0, + relation: "on", + }, + }), + ).toThrow("non-negative"); + }); + + test("rejects contradictory structured coordinates", () => { + expect(() => + marchingCoordinateToFieldPoint({ + side: { + side: 1, + yardLine: 50, + offsetSteps: 1, + relation: "inside", + }, + frontBack: { + reference: "front-sideline", + offsetSteps: 0, + relation: "on", + }, + }), + ).toThrow("50-yard line"); + }); +}); diff --git a/packages/mobile/src/field/__tests__/template.test.ts b/packages/mobile/src/field/__tests__/template.test.ts new file mode 100644 index 00000000..a6c05420 --- /dev/null +++ b/packages/mobile/src/field/__tests__/template.test.ts @@ -0,0 +1,71 @@ +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + getStandardFieldDimensionsInFeet, + getStandardFieldDimensionsInYards, +} from "../index"; + +describe("standard high-school field template", () => { + test("contains canonical field dimensions and references", () => { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + expect(field.goalToGoalYards).toBe(100); + expect(field.widthYards).toBeCloseTo(53 + 1 / 3); + expect(field.goalToGoalMeters).toBeCloseTo(91.44); + expect(field.widthMeters).toBeCloseTo(48.768); + expect(field.dimensions.highSchoolHashFromSidelineFeet).toBeCloseTo( + 53 + 4 / 12, + ); + expect(field.frontHashLine.coordinateMeters).toBeCloseTo(16.256); + expect(field.backHashLine.coordinateMeters).toBeCloseTo(32.512); + }); + + test("contains goal lines, sidelines, hashes, and every interior five-yard line", () => { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + expect(field.goalLines.map((line) => line.name)).toEqual([ + "Side 1 Goal Line", + "Side 2 Goal Line", + ]); + expect(field.sidelines.map((line) => line.name)).toEqual([ + "Front Sideline", + "Back Sideline", + ]); + expect(field.hashLines.map((line) => line.name)).toEqual([ + "HS FH", + "HS BH", + ]); + expect(field.fiveYardLines.map((line) => line.yardLineYards)).toEqual( + Array.from({ length: 19 }, (_, index) => (index + 1) * 5), + ); + expect(field.fiveYardLines[0].start.xMeters).toBeCloseTo(4.572); + expect(field.fiveYardLines[18].start.xMeters).toBeCloseTo(86.868); + }); + + test("includes two dimensioned numbers for each standard number position", () => { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + expect(field.yardNumbers).toHaveLength(18); + expect( + field.yardNumbers.filter((number) => number.label === "50"), + ).toHaveLength(2); + expect(field.yardNumbers.every((number) => number.widthMeters > 0)).toBe( + true, + ); + expect(field.yardNumbers.every((number) => number.heightMeters > 0)).toBe( + true, + ); + }); + + test("is deeply immutable", () => { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE as any; + expect(Object.isFrozen(field)).toBe(true); + expect(Object.isFrozen(field.goalLines)).toBe(true); + expect(Object.isFrozen(field.goalLines[0])).toBe(true); + expect(Object.isFrozen(field.goalLines[0].start)).toBe(true); + expect(() => { + field.goalLines.push(field.goalLines[0]); + }).toThrow(); + }); + + test("offers display-unit dimension helpers", () => { + expect(getStandardFieldDimensionsInFeet().goalToGoalFeet).toBeCloseTo(300); + expect(getStandardFieldDimensionsInYards().widthYards).toBeCloseTo(160 / 3); + }); +}); diff --git a/packages/mobile/src/field/__tests__/units.test.ts b/packages/mobile/src/field/__tests__/units.test.ts new file mode 100644 index 00000000..2b6244d1 --- /dev/null +++ b/packages/mobile/src/field/__tests__/units.test.ts @@ -0,0 +1,43 @@ +import { + FEET_PER_YARD, + METERS_PER_FOOT, + METERS_PER_YARD, + STANDARD_STEP_METERS, + STANDARD_STEPS_PER_FIVE_YARDS, + feetToMeters, + metersToFeet, + metersToStandardSteps, + metersToYards, + standardStepsToMeters, + standardStepsToYards, + yardsToMeters, + yardsToStandardSteps, +} from "../index"; + +describe("field units", () => { + test("uses the exact SI and standard 8-to-5 constants", () => { + expect(METERS_PER_YARD).toBe(0.9144); + expect(METERS_PER_FOOT).toBe(0.3048); + expect(FEET_PER_YARD).toBe(3); + expect(STANDARD_STEP_METERS).toBe(0.5715); + expect(STANDARD_STEPS_PER_FIVE_YARDS).toBe(8); + }); + + test("converts yards, feet, and standard steps", () => { + expect(yardsToMeters(1)).toBe(0.9144); + expect(feetToMeters(1)).toBe(0.3048); + expect(standardStepsToMeters(8)).toBeCloseTo(yardsToMeters(5)); + expect(metersToYards(yardsToMeters(12.5))).toBeCloseTo(12.5); + expect(metersToFeet(feetToMeters(53 + 4 / 12))).toBeCloseTo(53 + 4 / 12); + expect(metersToStandardSteps(standardStepsToMeters(3.25))).toBeCloseTo( + 3.25, + ); + expect(yardsToStandardSteps(5)).toBeCloseTo(8); + expect(standardStepsToYards(8)).toBeCloseTo(5); + }); + + test("rejects non-finite unit values", () => { + expect(() => yardsToMeters(Number.NaN)).toThrow("Yards"); + expect(() => metersToFeet(Number.POSITIVE_INFINITY)).toThrow("Meters"); + }); +}); diff --git a/packages/mobile/src/field/guidance.ts b/packages/mobile/src/field/guidance.ts new file mode 100644 index 00000000..f1b3abb8 --- /dev/null +++ b/packages/mobile/src/field/guidance.ts @@ -0,0 +1,62 @@ +import { assertFiniteFieldPoint, type FieldPoint } from "./types"; +import { + fieldPointDisplacementInStandardSteps, + metersToStandardSteps, +} from "./units"; +import { formatMarchingSteps } from "./marching"; + +export interface FieldGuidance { + /** Straight-line horizontal distance, in standard 8-to-5 steps. */ + readonly distanceSteps: number; + /** Signed target-minus-current displacement along canonical X/Y axes. */ + readonly xDisplacementSteps: number; + readonly yDisplacementSteps: number; + readonly xLabel: string; + readonly yLabel: string; +} + +function formatGuidanceAxis( + steps: number, + negativeDirection: string, + positiveDirection: string, +): string { + if (Math.abs(steps) < 1e-9) return "0 steps"; + const direction = steps < 0 ? negativeDirection : positiveDirection; + const magnitude = formatMarchingSteps(Math.abs(steps)); + const word = Number(magnitude) === 1 ? "step" : "steps"; + return `${magnitude} ${word} toward ${direction}`; +} + +/** + * Produces field-relative guidance only. It deliberately does not use device + * heading, phone orientation, compass data, or any other view-dependent input. + */ +export function calculateFieldGuidance( + current: FieldPoint, + target: FieldPoint, +): FieldGuidance { + assertFiniteFieldPoint(current, "Current point"); + assertFiniteFieldPoint(target, "Target point"); + const { xSteps, ySteps } = fieldPointDisplacementInStandardSteps( + current, + target, + ); + const xMeters = target.xMeters - current.xMeters; + const yMeters = target.yMeters - current.yMeters; + const distanceSteps = metersToStandardSteps(Math.hypot(xMeters, yMeters)); + return Object.freeze({ + distanceSteps, + xDisplacementSteps: xSteps, + yDisplacementSteps: ySteps, + xLabel: formatGuidanceAxis(xSteps, "Side 1", "Side 2"), + yLabel: formatGuidanceAxis( + ySteps, + "the front sideline", + "the back sideline", + ), + }); +} + +export const getFieldGuidance = calculateFieldGuidance; +export const calculateGuidance = calculateFieldGuidance; +export const getMovementGuidance = calculateFieldGuidance; diff --git a/packages/mobile/src/field/index.ts b/packages/mobile/src/field/index.ts new file mode 100644 index 00000000..a4a7eed5 --- /dev/null +++ b/packages/mobile/src/field/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export * from "./units"; +export * from "./template"; +export * from "./marching"; +export * from "./guidance"; diff --git a/packages/mobile/src/field/marching.ts b/packages/mobile/src/field/marching.ts new file mode 100644 index 00000000..4ed7119d --- /dev/null +++ b/packages/mobile/src/field/marching.ts @@ -0,0 +1,435 @@ +import { + assertFiniteFieldPoint, + type FieldLateralReference, + type FieldPoint, +} from "./types"; +import { + metersToStandardSteps, + metersToYards, + standardStepsToMeters, + yardsToMeters, +} from "./units"; +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + type StandardHighSchoolFieldTemplate, +} from "./template"; + +const EPSILON = 1e-9; + +export type MarchingSideReference = 1 | 2 | "center"; +export type MarchingSideRelation = "on" | "inside" | "outside"; +export type MarchingFrontBackRelation = "on" | "in-front-of" | "behind"; + +export interface MarchingSideCoordinate { + /** Side 1/2 is a goal-line end; center is the 50-yard reference. */ + readonly side: MarchingSideReference; + /** Side-relative yard line, from 0 through 50. */ + readonly yardLine: number; + /** Non-negative distance from the selected yard-line reference. */ + readonly offsetSteps: number; + readonly relation: MarchingSideRelation; +} + +export interface MarchingFrontBackCoordinate { + readonly reference: FieldLateralReference; + /** Non-negative distance from the selected lateral reference. */ + readonly offsetSteps: number; + readonly relation: MarchingFrontBackRelation; +} + +export interface MarchingCoordinate { + readonly side: MarchingSideCoordinate; + readonly frontBack: MarchingFrontBackCoordinate; + /** Set by conversion when the source point lies outside either boundary. */ + readonly outOfBounds?: readonly ("goal-to-goal" | "front-back")[]; +} + +function assertFinite(value: number, name: string): void { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be a finite number.`); + } +} + +/** + * Marching labels are intentionally quarter-step friendly. The canonical + * coordinate retains the unrounded value; only this display helper rounds it. + */ +export function formatMarchingSteps(steps: number): string { + assertFinite(steps, "Steps"); + const quarterSteps = Math.round(steps * 4) / 4; + const cleaned = Math.abs(quarterSteps) < EPSILON ? 0 : quarterSteps; + const text = Number(cleaned.toFixed(2)).toString(); + return text; +} + +function stepWord(steps: number, uppercase = true): string { + const value = formatMarchingSteps(steps); + const noun = Math.abs(Number(value)) === 1 ? "Step" : "Steps"; + return uppercase ? `${value} ${noun}` : `${value} ${noun.toLowerCase()}`; +} + +function yardLineText(yardLine: number): string { + return yardLine === 0 ? "Goal Line" : `${yardLine} yd ln`; +} + +interface XReference { + readonly xYards: number; + readonly side: MarchingSideReference; + readonly yardLine: number; +} + +function xReferences(): readonly XReference[] { + return Array.from({ length: 21 }, (_, index) => { + const xYards = index * 5; + if (xYards < 50) { + return { xYards, side: 1, yardLine: xYards }; + } + if (xYards > 50) { + return { xYards, side: 2, yardLine: 100 - xYards }; + } + return { xYards, side: "center", yardLine: 50 }; + }); +} + +const X_REFERENCES = xReferences(); + +interface LateralReference { + readonly reference: FieldLateralReference; + readonly yMeters: number; +} + +function lateralReferences( + template: StandardHighSchoolFieldTemplate, +): readonly LateralReference[] { + return [ + { + reference: "front-sideline", + yMeters: template.bounds.minYMeters, + }, + { + reference: "front-hash", + yMeters: template.frontHashLine.coordinateMeters, + }, + { + reference: "back-hash", + yMeters: template.backHashLine.coordinateMeters, + }, + { + reference: "back-sideline", + yMeters: template.bounds.maxYMeters, + }, + ]; +} + +/** + * Selects a nearest reference deterministically. Exact halfway ties choose + * the candidate closer to the field center. The two HS hashes are symmetric; + * when they tie at the exact lateral center, front hash wins as the stable + * front-to-back ordering. This avoids display flicker at reference midpoints. + */ +function nearestReference( + value: number, + references: readonly T[], + center: number, +): T { + let best = references[0]; + let bestDistance = Math.abs(value - best.coordinate); + for (const candidate of references.slice(1)) { + const distance = Math.abs(value - candidate.coordinate); + if (distance < bestDistance - EPSILON) { + best = candidate; + bestDistance = distance; + continue; + } + if (Math.abs(distance - bestDistance) <= EPSILON) { + const candidateCenterDistance = Math.abs(candidate.coordinate - center); + const bestCenterDistance = Math.abs(best.coordinate - center); + if ( + candidateCenterDistance < bestCenterDistance - EPSILON || + (Math.abs(candidateCenterDistance - bestCenterDistance) <= EPSILON && + candidate.coordinate < best.coordinate) + ) { + best = candidate; + bestDistance = distance; + } + } + } + return best; +} + +function sideRelation( + side: MarchingSideReference, + offsetXSteps: number, +): MarchingSideRelation { + if (Math.abs(offsetXSteps) <= EPSILON) return "on"; + if (side === "center") return "outside"; + const towardCenter = side === 1 ? offsetXSteps > 0 : offsetXSteps < 0; + return towardCenter ? "inside" : "outside"; +} + +function frontBackRelation(offsetYSteps: number): MarchingFrontBackRelation { + if (Math.abs(offsetYSteps) <= EPSILON) return "on"; + return offsetYSteps < 0 ? "in-front-of" : "behind"; +} + +function makeSideCoordinate(xMeters: number): MarchingSideCoordinate { + const xYards = metersToYards(xMeters); + const references = X_REFERENCES.map((reference) => ({ + ...reference, + coordinate: reference.xYards, + })); + const nearest = nearestReference(xYards, references, 50); + const offsetXSteps = metersToStandardSteps( + xMeters - yardsToMeters(nearest.xYards), + ); + // Exactly on the 50 has no side. Any offset from the 50 is presented on the + // point's actual side and uses the normal inside/outside vocabulary. + const side = + nearest.side === "center" && Math.abs(offsetXSteps) > EPSILON + ? xMeters < yardsToMeters(50) + ? 1 + : 2 + : nearest.side; + return Object.freeze({ + side, + yardLine: nearest.yardLine, + offsetSteps: Math.abs(offsetXSteps), + relation: sideRelation(side, offsetXSteps), + }); +} + +function makeFrontBackCoordinate( + yMeters: number, + template: StandardHighSchoolFieldTemplate, +): MarchingFrontBackCoordinate { + const references = lateralReferences(template).map((reference) => ({ + ...reference, + coordinate: reference.yMeters, + })); + const nearest = nearestReference( + yMeters, + references, + template.bounds.maxYMeters / 2, + ); + const offsetYSteps = metersToStandardSteps(yMeters - nearest.yMeters); + return Object.freeze({ + reference: nearest.reference, + offsetSteps: Math.abs(offsetYSteps), + relation: frontBackRelation(offsetYSteps), + }); +} + +function getOutOfBounds( + point: FieldPoint, + template: StandardHighSchoolFieldTemplate, +): readonly ("goal-to-goal" | "front-back")[] | undefined { + const outOfBounds: ("goal-to-goal" | "front-back")[] = []; + if ( + point.xMeters < template.bounds.minXMeters - EPSILON || + point.xMeters > template.bounds.maxXMeters + EPSILON + ) { + outOfBounds.push("goal-to-goal"); + } + if ( + point.yMeters < template.bounds.minYMeters - EPSILON || + point.yMeters > template.bounds.maxYMeters + EPSILON + ) { + outOfBounds.push("front-back"); + } + return outOfBounds.length > 0 ? Object.freeze(outOfBounds) : undefined; +} + +export function fieldPointToMarchingCoordinate( + point: FieldPoint, + template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): MarchingCoordinate { + assertFiniteFieldPoint(point); + const outOfBounds = getOutOfBounds(point, template); + return Object.freeze({ + side: makeSideCoordinate(point.xMeters), + frontBack: makeFrontBackCoordinate(point.yMeters, template), + ...(outOfBounds ? { outOfBounds } : {}), + }); +} + +function assertYardLine(yardLine: number): void { + assertFinite(yardLine, "Marching yard line"); + if (yardLine < 0 || yardLine > 50) { + throw new RangeError("Marching yard line must be between 0 and 50."); + } + if (Math.abs(yardLine / 5 - Math.round(yardLine / 5)) > EPSILON) { + throw new RangeError("Marching yard line must be a five-yard line."); + } +} + +function assertOffset(offsetSteps: number, name: string): void { + assertFinite(offsetSteps, name); + if (offsetSteps < 0) { + throw new RangeError(`${name} must be non-negative.`); + } +} + +function sideCoordinateToX(coordinate: MarchingSideCoordinate): number { + assertYardLine(coordinate.yardLine); + assertOffset(coordinate.offsetSteps, "Marching side offsetSteps"); + if (coordinate.relation === "on" && coordinate.offsetSteps > EPSILON) { + throw new RangeError('An "on" marching coordinate must have zero offset.'); + } + if ( + coordinate.yardLine === 50 && + ((coordinate.side === "center" && coordinate.relation !== "on") || + (coordinate.side !== "center" && coordinate.relation !== "outside")) + ) { + throw new RangeError( + "The 50-yard line uses center/on or Side 1/2 outside coordinates.", + ); + } + const lineX = + coordinate.side === "center" + ? yardsToMeters(50) + : yardsToMeters( + coordinate.side === 1 + ? coordinate.yardLine + : 100 - coordinate.yardLine, + ); + if (coordinate.side === "center") { + if (coordinate.relation === "on") return lineX; + throw new RangeError('A center marching reference must use "on".'); + } + if (coordinate.relation === "on") return lineX; + if (coordinate.relation !== "inside" && coordinate.relation !== "outside") { + throw new RangeError( + 'A Side 1/2 marching reference must use "on", "inside", or "outside".', + ); + } + const towardSide2 = + coordinate.side === 1 + ? coordinate.relation === "inside" + : coordinate.relation === "outside"; + const offset = standardStepsToMeters(coordinate.offsetSteps); + return towardSide2 ? lineX + offset : lineX - offset; +} + +function frontBackCoordinateToY( + coordinate: MarchingFrontBackCoordinate, + template: StandardHighSchoolFieldTemplate, +): number { + assertOffset(coordinate.offsetSteps, "Marching front/back offsetSteps"); + if (coordinate.relation === "on" && coordinate.offsetSteps > EPSILON) { + throw new RangeError('An "on" marching coordinate must have zero offset.'); + } + const yByReference: Record = { + "front-sideline": template.bounds.minYMeters, + "front-hash": template.frontHashLine.coordinateMeters, + "back-hash": template.backHashLine.coordinateMeters, + "back-sideline": template.bounds.maxYMeters, + }; + const lineY = yByReference[coordinate.reference]; + if (lineY === undefined) { + throw new RangeError( + `Unknown marching lateral reference: ${String(coordinate.reference)}.`, + ); + } + if (coordinate.relation === "on") return lineY; + if (coordinate.relation === "in-front-of") { + return lineY - standardStepsToMeters(coordinate.offsetSteps); + } + if (coordinate.relation === "behind") { + return lineY + standardStepsToMeters(coordinate.offsetSteps); + } + throw new RangeError( + 'A marching front/back reference must use "on", "in-front-of", or "behind".', + ); +} + +export function marchingCoordinateToFieldPoint( + coordinate: MarchingCoordinate, + template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldPoint { + if (!coordinate || !coordinate.side || !coordinate.frontBack) { + throw new TypeError( + "A marching coordinate requires side and frontBack values.", + ); + } + const point = { + xMeters: sideCoordinateToX(coordinate.side), + yMeters: frontBackCoordinateToY(coordinate.frontBack, template), + }; + assertFiniteFieldPoint(point, "Converted field point"); + return Object.freeze(point); +} + +export const fieldPointToMarching = fieldPointToMarchingCoordinate; +export const marchingToFieldPoint = marchingCoordinateToFieldPoint; +export const fieldPositionToMarchingCoordinate = fieldPointToMarchingCoordinate; +export const marchingCoordinateToFieldPosition = marchingCoordinateToFieldPoint; + +function formatSideCoordinate(coordinate: MarchingSideCoordinate): string { + const line = yardLineText(coordinate.yardLine); + if (coordinate.relation === "on") { + return coordinate.side === "center" + ? `On ${line}` + : `Side ${coordinate.side}: On ${line}`; + } + const steps = stepWord(coordinate.offsetSteps); + if (coordinate.side === "center") return `On ${line}`; + return `Side ${coordinate.side}: ${steps} ${coordinate.relation} ${line}`; +} + +function lateralReferenceText(reference: FieldLateralReference): string { + switch (reference) { + case "front-sideline": + return "Front Sideline"; + case "front-hash": + return "HS FH"; + case "back-hash": + return "HS BH"; + case "back-sideline": + return "Back Sideline"; + } +} + +function formatFrontBackCoordinate( + coordinate: MarchingFrontBackCoordinate, +): string { + const reference = lateralReferenceText(coordinate.reference); + if (coordinate.relation === "on") return `On ${reference}`; + return `${stepWord(coordinate.offsetSteps)} ${ + coordinate.relation === "behind" ? "behind" : "in front of" + } ${reference}`; +} + +export function formatMarchingSide(coordinate: MarchingSideCoordinate): string { + return formatSideCoordinate(coordinate); +} + +export const formatMarchingSideCoordinate = formatMarchingSide; + +export function formatMarchingFrontBack( + coordinate: MarchingFrontBackCoordinate, +): string { + return formatFrontBackCoordinate(coordinate); +} + +export const formatMarchingFrontBackCoordinate = formatMarchingFrontBack; + +export function formatMarchingCoordinate( + coordinateOrPoint: MarchingCoordinate | FieldPoint, + template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): string { + const coordinate = + "side" in coordinateOrPoint + ? coordinateOrPoint + : fieldPointToMarchingCoordinate(coordinateOrPoint, template); + const parts = [ + formatSideCoordinate(coordinate.side), + formatFrontBackCoordinate(coordinate.frontBack), + ]; + const formatted = parts.join("; "); + return coordinate.outOfBounds?.length + ? `Out of Bounds — ${formatted}` + : formatted; +} + +export const formatMarchingPosition = formatMarchingCoordinate; +export const formatFieldPointAsMarching = formatMarchingCoordinate; diff --git a/packages/mobile/src/field/template.ts b/packages/mobile/src/field/template.ts new file mode 100644 index 00000000..c2d9281b --- /dev/null +++ b/packages/mobile/src/field/template.ts @@ -0,0 +1,252 @@ +import { + feetToMeters, + metersToFeet, + metersToYards, + yardsToMeters, +} from "./units"; +import type { FieldPoint } from "./types"; + +export type FieldLineKind = + | "goal-line" + | "sideline" + | "hash-line" + | "yard-line"; + +export interface FieldLine { + readonly kind: FieldLineKind; + readonly name: string; + readonly axis: "x" | "y"; + readonly coordinateMeters: number; + readonly start: FieldPoint; + readonly end: FieldPoint; + /** Absolute goal-to-goal yard coordinate, when the line has one. */ + readonly yardLineYards?: number; +} + +export interface FieldYardNumber { + readonly label: string; + /** Side-relative number printed on the field (10, 20, 30, 40, or 50). */ + readonly yardLineYards: number; + readonly xMeters: number; + readonly yMeters: number; + readonly side: "front" | "back"; + readonly widthMeters: number; + readonly heightMeters: number; +} + +export interface StandardHighSchoolFieldDimensions { + readonly goalToGoalYards: 100; + readonly widthYards: number; + readonly goalToGoalMeters: number; + readonly widthMeters: number; + readonly fiveYardLineSpacingYards: 5; + readonly fiveYardLineSpacingMeters: number; + readonly highSchoolHashFromSidelineFeet: number; + readonly highSchoolHashFromSidelineMeters: number; + readonly yardNumberInsetFromSidelineFeet: number; + readonly yardNumberInsetFromSidelineMeters: number; + readonly yardNumberWidthFeet: number; + readonly yardNumberHeightFeet: number; + readonly yardNumberWidthMeters: number; + readonly yardNumberHeightMeters: number; +} + +export interface StandardHighSchoolFieldTemplate { + readonly name: "standard-high-school"; + readonly dimensions: StandardHighSchoolFieldDimensions; + readonly goalToGoalYards: 100; + readonly widthYards: number; + readonly goalToGoalMeters: number; + readonly widthMeters: number; + readonly bounds: { + readonly minXMeters: 0; + readonly maxXMeters: number; + readonly minYMeters: 0; + readonly maxYMeters: number; + }; + readonly goalLines: readonly [FieldLine, FieldLine]; + readonly sidelines: readonly [FieldLine, FieldLine]; + readonly hashLines: readonly [FieldLine, FieldLine]; + readonly frontHashLine: FieldLine; + readonly backHashLine: FieldLine; + /** The 19 interior five-yard lines; goal lines are listed separately. */ + readonly fiveYardLines: readonly FieldLine[]; + /** The 21 multiples of five yards, including the two goal lines. */ + readonly allFiveYardLines: readonly FieldLine[]; + readonly yardLines: readonly FieldLine[]; + readonly yardNumbers: readonly FieldYardNumber[]; +} + +const FIELD_LENGTH_YARDS = 100 as const; +const FIELD_WIDTH_YARDS = 160 / 3; +const FIELD_LENGTH_METERS = yardsToMeters(FIELD_LENGTH_YARDS); +const FIELD_WIDTH_METERS = feetToMeters(160); +const HASH_FROM_SIDELINE_FEET = 53 + 4 / 12; +const HASH_FROM_SIDELINE_METERS = feetToMeters(HASH_FROM_SIDELINE_FEET); +const FRONT_HASH_Y_METERS = HASH_FROM_SIDELINE_METERS; +const BACK_HASH_Y_METERS = FIELD_WIDTH_METERS - HASH_FROM_SIDELINE_METERS; +const YARD_NUMBER_INSET_FEET = 12; +const YARD_NUMBER_INSET_METERS = feetToMeters(YARD_NUMBER_INSET_FEET); +const YARD_NUMBER_WIDTH_FEET = 4; +const YARD_NUMBER_HEIGHT_FEET = 6; +const YARD_NUMBER_WIDTH_METERS = feetToMeters(YARD_NUMBER_WIDTH_FEET); +const YARD_NUMBER_HEIGHT_METERS = feetToMeters(YARD_NUMBER_HEIGHT_FEET); + +export const STANDARD_FIELD_LENGTH_YARDS = FIELD_LENGTH_YARDS; +export const STANDARD_FIELD_WIDTH_YARDS = FIELD_WIDTH_YARDS; +export const STANDARD_FIELD_LENGTH_METERS = FIELD_LENGTH_METERS; +export const STANDARD_FIELD_WIDTH_METERS = FIELD_WIDTH_METERS; +export const HIGH_SCHOOL_HASH_DISTANCE_FEET = HASH_FROM_SIDELINE_FEET; +export const HIGH_SCHOOL_HASH_DISTANCE_METERS = HASH_FROM_SIDELINE_METERS; + +function point(xMeters: number, yMeters: number): FieldPoint { + return Object.freeze({ xMeters, yMeters }); +} + +function xLine(kind: FieldLineKind, name: string, xYards: number): FieldLine { + const xMeters = yardsToMeters(xYards); + return Object.freeze({ + kind, + name, + axis: "x", + coordinateMeters: xMeters, + start: point(xMeters, 0), + end: point(xMeters, FIELD_WIDTH_METERS), + yardLineYards: xYards, + }); +} + +function yLine(kind: FieldLineKind, name: string, yMeters: number): FieldLine { + return Object.freeze({ + kind, + name, + axis: "y", + coordinateMeters: yMeters, + start: point(0, yMeters), + end: point(FIELD_LENGTH_METERS, yMeters), + }); +} + +function makeYardNumbers(): readonly FieldYardNumber[] { + const numbers: FieldYardNumber[] = []; + for (const absoluteYards of [10, 20, 30, 40, 50, 60, 70, 80, 90]) { + const sideRelativeYards = Math.min(absoluteYards, 100 - absoluteYards); + const label = String(sideRelativeYards); + const xMeters = yardsToMeters(absoluteYards); + for (const side of ["front", "back"] as const) { + const yMeters = + side === "front" + ? YARD_NUMBER_INSET_METERS + : FIELD_WIDTH_METERS - YARD_NUMBER_INSET_METERS; + numbers.push( + Object.freeze({ + label, + yardLineYards: sideRelativeYards, + xMeters, + yMeters, + side, + widthMeters: YARD_NUMBER_WIDTH_METERS, + heightMeters: YARD_NUMBER_HEIGHT_METERS, + }), + ); + } + } + return Object.freeze(numbers); +} + +const goalLines = Object.freeze([ + xLine("goal-line", "Side 1 Goal Line", 0), + xLine("goal-line", "Side 2 Goal Line", 100), +] as const); +const sidelines = Object.freeze([ + yLine("sideline", "Front Sideline", 0), + yLine("sideline", "Back Sideline", FIELD_WIDTH_METERS), +] as const); +const hashLines = Object.freeze([ + yLine("hash-line", "HS FH", FRONT_HASH_Y_METERS), + yLine("hash-line", "HS BH", BACK_HASH_Y_METERS), +] as const); +const fiveYardLines = Object.freeze( + Array.from({ length: 19 }, (_, index) => { + const yardLineYards = (index + 1) * 5; + return xLine("yard-line", `${yardLineYards} yd Line`, yardLineYards); + }), +); +const allFiveYardLines = Object.freeze([ + goalLines[0], + ...fiveYardLines, + goalLines[1], +]); + +const dimensions: StandardHighSchoolFieldDimensions = Object.freeze({ + goalToGoalYards: FIELD_LENGTH_YARDS, + widthYards: FIELD_WIDTH_YARDS, + goalToGoalMeters: FIELD_LENGTH_METERS, + widthMeters: FIELD_WIDTH_METERS, + fiveYardLineSpacingYards: 5, + fiveYardLineSpacingMeters: yardsToMeters(5), + highSchoolHashFromSidelineFeet: HASH_FROM_SIDELINE_FEET, + highSchoolHashFromSidelineMeters: HASH_FROM_SIDELINE_METERS, + yardNumberInsetFromSidelineFeet: YARD_NUMBER_INSET_FEET, + yardNumberInsetFromSidelineMeters: YARD_NUMBER_INSET_METERS, + yardNumberWidthFeet: YARD_NUMBER_WIDTH_FEET, + yardNumberHeightFeet: YARD_NUMBER_HEIGHT_FEET, + yardNumberWidthMeters: YARD_NUMBER_WIDTH_METERS, + yardNumberHeightMeters: YARD_NUMBER_HEIGHT_METERS, +}); + +/** + * One shared, immutable geometry source for field conversion and future Skia + * rendering. Coordinates deliberately remain meters even when labels are in + * yards/feet so no display rounding leaks into domain calculations. + */ +export const STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE: StandardHighSchoolFieldTemplate = + Object.freeze({ + name: "standard-high-school", + dimensions, + goalToGoalYards: FIELD_LENGTH_YARDS, + widthYards: FIELD_WIDTH_YARDS, + goalToGoalMeters: FIELD_LENGTH_METERS, + widthMeters: FIELD_WIDTH_METERS, + bounds: Object.freeze({ + minXMeters: 0, + maxXMeters: FIELD_LENGTH_METERS, + minYMeters: 0, + maxYMeters: FIELD_WIDTH_METERS, + }), + goalLines, + sidelines, + hashLines, + frontHashLine: hashLines[0], + backHashLine: hashLines[1], + fiveYardLines, + allFiveYardLines, + yardLines: fiveYardLines, + yardNumbers: makeYardNumbers(), + }); + +/** Short aliases used by drawing callers and older design notes. */ +export const STANDARD_HIGH_SCHOOL_FIELD = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +export const STANDARD_FIELD_TEMPLATE = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + +/** + * The template uses meters internally; this helper is useful when displaying + * dimensions without duplicating conversion logic in a renderer. + */ +export function getStandardFieldDimensionsInFeet() { + return Object.freeze({ + goalToGoalFeet: metersToFeet(FIELD_LENGTH_METERS), + widthFeet: metersToFeet(FIELD_WIDTH_METERS), + frontHashFromSidelineFeet: metersToFeet(FRONT_HASH_Y_METERS), + backHashFromFrontSidelineFeet: metersToFeet(BACK_HASH_Y_METERS), + }); +} + +export function getStandardFieldDimensionsInYards() { + return Object.freeze({ + goalToGoalYards: metersToYards(FIELD_LENGTH_METERS), + widthYards: metersToYards(FIELD_WIDTH_METERS), + frontHashFromFrontSidelineYards: metersToYards(FRONT_HASH_Y_METERS), + backHashFromFrontSidelineYards: metersToYards(BACK_HASH_Y_METERS), + }); +} diff --git a/packages/mobile/src/field/types.ts b/packages/mobile/src/field/types.ts new file mode 100644 index 00000000..f73eda29 --- /dev/null +++ b/packages/mobile/src/field/types.ts @@ -0,0 +1,79 @@ +/** The two goal-line ends of a football field. */ +export type FieldSide = 1 | 2; + +/** The four named lateral references used by marching coordinates. */ +export type FieldLateralReference = + | "front-sideline" + | "front-hash" + | "back-hash" + | "back-sideline"; + +/** + * A two-dimensional point in the canonical field coordinate system. + * + * The origin is the Side 1 goal line/front sideline intersection. X increases + * toward the Side 2 goal line and Y increases toward the back sideline. + */ +export interface FieldPoint { + readonly xMeters: number; + readonly yMeters: number; +} + +/** A field point with an optional vertical coordinate. Z increases upward. */ +export interface FieldPosition extends FieldPoint { + readonly zMeters?: number; +} + +/** The canonical origin and axis directions, useful to consumers drawing axes. */ +export interface FieldCoordinateOrigin { + readonly xMeters: 0; + readonly yMeters: 0; + readonly zMeters: 0; + readonly side: 1; + readonly lateralReference: "front-sideline"; +} + +export type FieldOrigin = FieldCoordinateOrigin; + +export const FIELD_ORIGIN: FieldCoordinateOrigin = Object.freeze({ + xMeters: 0, + yMeters: 0, + zMeters: 0, + side: 1, + lateralReference: "front-sideline", +}); + +export const FIELD_COORDINATE_ORIGIN = FIELD_ORIGIN; + +export const FIELD_AXIS_DIRECTIONS = Object.freeze({ + x: "toward-side-2", + y: "toward-back-sideline", + z: "up", +} as const); + +/** Throws a clear error when a point contains a non-finite coordinate. */ +export function assertFiniteFieldPoint( + point: FieldPoint, + name = "Field point", +): void { + if (point === null || typeof point !== "object") { + throw new TypeError(`${name} must be an object with xMeters and yMeters.`); + } + if (!Number.isFinite(point.xMeters)) { + throw new RangeError(`${name}.xMeters must be a finite number.`); + } + if (!Number.isFinite(point.yMeters)) { + throw new RangeError(`${name}.yMeters must be a finite number.`); + } +} + +/** Throws a clear error when a position contains a non-finite coordinate. */ +export function assertFiniteFieldPosition( + position: FieldPosition, + name = "Field position", +): void { + assertFiniteFieldPoint(position, name); + if (position.zMeters !== undefined && !Number.isFinite(position.zMeters)) { + throw new RangeError(`${name}.zMeters must be a finite number.`); + } +} diff --git a/packages/mobile/src/field/units.ts b/packages/mobile/src/field/units.ts new file mode 100644 index 00000000..0611fb61 --- /dev/null +++ b/packages/mobile/src/field/units.ts @@ -0,0 +1,89 @@ +import { assertFiniteFieldPoint } from "./types"; + +/** Exact SI conversion constants used by every field-domain calculation. */ +export const METERS_PER_YARD = 0.9144; +export const METERS_PER_FOOT = 0.3048; +export const FEET_PER_YARD = 3; +export const YARDS_PER_FOOT = 1 / FEET_PER_YARD; + +/** Standard marching is eight steps over five yards (22.5 inches per step). */ +export const STANDARD_STEPS_PER_FIVE_YARDS = 8; +export const STANDARD_STEP_METERS = 0.5715; +export const FIVE_YARDS_IN_STANDARD_STEPS = STANDARD_STEPS_PER_FIVE_YARDS; +export const STANDARD_STEPS_PER_5_YARDS = STANDARD_STEPS_PER_FIVE_YARDS; +export const STANDARD_8_TO_5_STEPS = STANDARD_STEPS_PER_FIVE_YARDS; +export const METERS_PER_STANDARD_STEP = STANDARD_STEP_METERS; + +export const YARDS_PER_STANDARD_STEP = STANDARD_STEP_METERS / METERS_PER_YARD; +export const FEET_PER_STANDARD_STEP = STANDARD_STEP_METERS / METERS_PER_FOOT; +export const STANDARD_STEPS_PER_YARD = 1 / YARDS_PER_STANDARD_STEP; +export const STANDARD_STEPS_PER_FOOT = STANDARD_STEPS_PER_YARD / FEET_PER_YARD; + +function assertFinite(value: number, name: string): void { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be a finite number.`); + } +} + +export function yardsToMeters(yards: number): number { + assertFinite(yards, "Yards"); + return yards * METERS_PER_YARD; +} + +export function metersToYards(meters: number): number { + assertFinite(meters, "Meters"); + return meters / METERS_PER_YARD; +} + +export function feetToMeters(feet: number): number { + assertFinite(feet, "Feet"); + return feet * METERS_PER_FOOT; +} + +export function metersToFeet(meters: number): number { + assertFinite(meters, "Meters"); + return meters / METERS_PER_FOOT; +} + +export function standardStepsToMeters(steps: number): number { + assertFinite(steps, "Standard steps"); + return steps * STANDARD_STEP_METERS; +} + +export function metersToStandardSteps(meters: number): number { + assertFinite(meters, "Meters"); + return meters / STANDARD_STEP_METERS; +} + +export function yardsToStandardSteps(yards: number): number { + assertFinite(yards, "Yards"); + return yards * STANDARD_STEPS_PER_YARD; +} + +export function standardStepsToYards(steps: number): number { + assertFinite(steps, "Standard steps"); + return steps * YARDS_PER_STANDARD_STEP; +} + +export function feetToStandardSteps(feet: number): number { + assertFinite(feet, "Feet"); + return feet * STANDARD_STEPS_PER_FOOT; +} + +export function standardStepsToFeet(steps: number): number { + assertFinite(steps, "Standard steps"); + return steps * FEET_PER_STANDARD_STEP; +} + +/** Returns the signed X/Y displacement between two field points in steps. */ +export function fieldPointDisplacementInStandardSteps( + from: { xMeters: number; yMeters: number }, + to: { xMeters: number; yMeters: number }, +): { xSteps: number; ySteps: number } { + assertFiniteFieldPoint(from, "From point"); + assertFiniteFieldPoint(to, "To point"); + return { + xSteps: metersToStandardSteps(to.xMeters - from.xMeters), + ySteps: metersToStandardSteps(to.yMeters - from.yMeters), + }; +} diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index e7bed94c..fe464732 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -1 +1,35 @@ export * from "./pans-manager"; +// PANS map-units already exports METERS_PER_FOOT. Re-export the remaining +// field-unit symbols explicitly so the root barrel remains unambiguous while +// @eight2five/mobile/field exposes the complete field barrel. +export * from "./field/types"; +export { + FEET_PER_STANDARD_STEP, + FEET_PER_YARD, + FIVE_YARDS_IN_STANDARD_STEPS, + METERS_PER_STANDARD_STEP, + METERS_PER_YARD, + STANDARD_8_TO_5_STEPS, + STANDARD_STEP_METERS, + STANDARD_STEPS_PER_5_YARDS, + STANDARD_STEPS_PER_FIVE_YARDS, + STANDARD_STEPS_PER_FOOT, + STANDARD_STEPS_PER_YARD, + YARDS_PER_FOOT, + YARDS_PER_STANDARD_STEP, + feetToMeters, + feetToStandardSteps, + fieldPointDisplacementInStandardSteps, + metersToFeet, + metersToStandardSteps, + metersToYards, + standardStepsToFeet, + standardStepsToMeters, + standardStepsToYards, + yardsToMeters, + yardsToStandardSteps, +} from "./field/units"; +export * from "./field/template"; +export * from "./field/marching"; +export * from "./field/guidance"; +export * from "./drill"; From 12d72fde1065260060c620580c4f0d4dd28df279 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 21:33:57 -0500 Subject: [PATCH 004/101] feat(mobile): add drill and settings persistence --- apps/mobile/app/_layout.tsx | 34 +- .../navigation/tab-bar-visibility-context.tsx | 14 +- .../__tests__/app-settings-store.test.ts | 110 +++ apps/mobile/src/state/app-settings-store.tsx | 202 +++++ apps/mobile/src/state/field-session-store.ts | 40 + packages/mobile/package.json | 4 +- .../mobile/src/drill/SqliteDrillRepository.ts | 700 ++++++++++++++++++ .../drill/__tests__/sqlite-repository.test.ts | 437 +++++++++++ packages/mobile/src/drill/index.ts | 1 + packages/mobile/src/index.ts | 2 + packages/mobile/src/mobile-repositories.ts | 47 ++ .../src/settings/SqliteSettingsRepository.ts | 200 +++++ .../src/settings/__tests__/repository.test.ts | 179 +++++ packages/mobile/src/settings/index.ts | 2 + packages/mobile/src/settings/types.ts | 183 +++++ .../storage/__tests__/mobileDatabase.test.ts | 70 ++ packages/mobile/src/storage/index.ts | 2 + packages/mobile/src/storage/mobileDatabase.ts | 138 ++++ 18 files changed, 2351 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/state/__tests__/app-settings-store.test.ts create mode 100644 apps/mobile/src/state/app-settings-store.tsx create mode 100644 apps/mobile/src/state/field-session-store.ts create mode 100644 packages/mobile/src/drill/SqliteDrillRepository.ts create mode 100644 packages/mobile/src/drill/__tests__/sqlite-repository.test.ts create mode 100644 packages/mobile/src/mobile-repositories.ts create mode 100644 packages/mobile/src/settings/SqliteSettingsRepository.ts create mode 100644 packages/mobile/src/settings/__tests__/repository.test.ts create mode 100644 packages/mobile/src/settings/index.ts create mode 100644 packages/mobile/src/settings/types.ts create mode 100644 packages/mobile/src/storage/__tests__/mobileDatabase.test.ts create mode 100644 packages/mobile/src/storage/index.ts create mode 100644 packages/mobile/src/storage/mobileDatabase.ts diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 83c1a684..248f054c 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -7,6 +7,10 @@ import { GluestackUIProvider } from "@eight2five/ui/components/gluestack-ui-prov import { useEight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; import { TabBarVisibilityProvider } from "../src/navigation/tab-bar-visibility-context"; +import { + AppSettingsProvider, + useAppSettingsSnapshot, +} from "../src/state/app-settings-store"; import "../global.css"; @@ -28,16 +32,28 @@ export default function MobileRootLayout() { return ( - - - - + + + ); } + +function MobileNavigation({ backgroundColor }: { backgroundColor: string }) { + const { settings } = useAppSettingsSnapshot(); + + return ( + + + + + ); +} diff --git a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx index bf019f81..db031bba 100644 --- a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx +++ b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx @@ -29,14 +29,16 @@ const TabBarVisibilityContext = React.createContext< export function TabBarVisibilityProvider({ children, + drillFeaturesEnabled, }: { children: React.ReactNode; + drillFeaturesEnabled: boolean; }) { const router = useRouter(); - const [state, dispatch] = React.useReducer( - reduceMobileTabNavigationState, - INITIAL_MOBILE_TAB_NAVIGATION_STATE, - ); + const [state, dispatch] = React.useReducer(reduceMobileTabNavigationState, { + ...INITIAL_MOBILE_TAB_NAVIGATION_STATE, + drillFeaturesEnabled, + }); const setFieldPresentation = React.useCallback( ({ focused, landscape }: FieldPresentation) => { @@ -59,6 +61,10 @@ export function TabBarVisibilityProvider({ [router, state.drillFeaturesEnabled], ); + React.useEffect(() => { + reconfigureDrillFeatures(drillFeaturesEnabled); + }, [drillFeaturesEnabled, reconfigureDrillFeatures]); + const value = React.useMemo( () => ({ ...state, diff --git a/apps/mobile/src/state/__tests__/app-settings-store.test.ts b/apps/mobile/src/state/__tests__/app-settings-store.test.ts new file mode 100644 index 00000000..f2a22b96 --- /dev/null +++ b/apps/mobile/src/state/__tests__/app-settings-store.test.ts @@ -0,0 +1,110 @@ +import { + DEFAULT_APP_SETTINGS, + type AppSettings, +} from "@eight2five/mobile/settings"; +import type { OpenMobileRepositoriesResult } from "@eight2five/mobile/storage"; + +import { AppSettingsStore } from "../app-settings-store"; +import { selectFieldSession } from "../field-session-store"; + +describe("AppSettingsStore", () => { + test("hydrates, serializes updates, resets preferences, and closes storage", async () => { + let settings: AppSettings = { + ...DEFAULT_APP_SETTINGS, + drillTerminology: "sets", + activeDrillId: "drill-1", + selectedDrillPageId: "page-1", + }; + const close = jest.fn(async () => undefined); + const settingsRepository = { + load: jest.fn(async () => settings), + update: jest.fn(async (partial) => { + settings = { ...settings, ...partial }; + return settings; + }), + resetPreferences: jest.fn(async () => { + settings = { + ...DEFAULT_APP_SETTINGS, + activeDrillId: settings.activeDrillId, + selectedDrillPageId: settings.selectedDrillPageId, + }; + return settings; + }), + }; + const drillRepository = { + setActiveDrill: jest.fn(async (id: string | null) => { + settings = { + ...settings, + activeDrillId: id, + selectedDrillPageId: null, + }; + return settings; + }), + setSelectedDrillPage: jest.fn(async (id: string | null) => { + settings = { ...settings, selectedDrillPageId: id }; + return settings; + }), + }; + const storage = { + settingsRepository, + drillRepository, + close, + } as unknown as OpenMobileRepositoriesResult; + const store = new AppSettingsStore(async () => storage); + const listener = jest.fn(); + store.subscribe(listener); + + await store.initialize(); + expect(store.getSnapshot()).toMatchObject({ + status: "ready", + settings: { drillTerminology: "sets", activeDrillId: "drill-1" }, + }); + + await Promise.all([ + store.update({ guidanceEnabled: false }), + store.update({ fieldPerspective: "performer" }), + ]); + expect(settingsRepository.update.mock.calls).toEqual([ + [{ guidanceEnabled: false }], + [{ fieldPerspective: "performer" }], + ]); + + await store.resetPreferences(); + expect(store.getSnapshot().settings).toEqual({ + ...DEFAULT_APP_SETTINGS, + activeDrillId: "drill-1", + selectedDrillPageId: "page-1", + }); + expect(listener).toHaveBeenCalled(); + + await store.dispose(); + expect(close).toHaveBeenCalledTimes(1); + }); + + test("publishes initialization errors and rejects writes before ready", async () => { + const store = new AppSettingsStore(async () => { + throw new Error("open failed"); + }); + await store.initialize(); + expect(store.getSnapshot()).toMatchObject({ + status: "error", + error: new Error("open failed"), + }); + await expect(store.update({ guidanceEnabled: false })).rejects.toThrow( + "not ready", + ); + }); + + test("derives the persisted field session contract", () => { + expect( + selectFieldSession({ + ...DEFAULT_APP_SETTINGS, + activeDrillId: "drill-1", + selectedDrillPageId: "page-2", + }), + ).toEqual({ + activeDrillId: "drill-1", + selectedDrillPageId: "page-2", + }); + }); +}); diff --git a/apps/mobile/src/state/app-settings-store.tsx b/apps/mobile/src/state/app-settings-store.tsx new file mode 100644 index 00000000..d78d436b --- /dev/null +++ b/apps/mobile/src/state/app-settings-store.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import { + DEFAULT_APP_SETTINGS, + type AppSettings, + type AppSettingsUpdate, +} from "@eight2five/mobile/settings"; +import { + openMobileRepositories, + type OpenMobileRepositoriesResult, +} from "@eight2five/mobile/storage"; + +export type AppSettingsStoreStatus = "loading" | "ready" | "error"; + +export interface AppSettingsStoreSnapshot { + readonly status: AppSettingsStoreStatus; + readonly settings: AppSettings; + readonly error?: Error; +} + +export type OpenAppSettingsStorage = + () => Promise; + +const INITIAL_SNAPSHOT: AppSettingsStoreSnapshot = Object.freeze({ + status: "loading", + settings: DEFAULT_APP_SETTINGS, +}); + +/** Owns the app database lifecycle and publishes one stable settings snapshot. */ +export class AppSettingsStore { + private snapshot: AppSettingsStoreSnapshot = INITIAL_SNAPSHOT; + private readonly listeners = new Set<() => void>(); + private storage?: OpenMobileRepositoriesResult; + private lifecycleGeneration = 0; + private writeQueue: Promise = Promise.resolve(); + + constructor( + private readonly openStorage: OpenAppSettingsStorage = () => + openMobileRepositories(), + ) {} + + readonly getSnapshot = (): AppSettingsStoreSnapshot => this.snapshot; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + async initialize(): Promise { + const generation = ++this.lifecycleGeneration; + this.publish(INITIAL_SNAPSHOT); + let storage: OpenMobileRepositoriesResult | undefined; + try { + storage = await this.openStorage(); + if (generation !== this.lifecycleGeneration) { + await storage.close(); + return; + } + const settings = await storage.settingsRepository.load(); + if (generation !== this.lifecycleGeneration) { + await storage.close(); + return; + } + this.storage = storage; + this.publish(Object.freeze({ status: "ready", settings })); + } catch (cause) { + if (storage && storage !== this.storage) await storage.close(); + if (generation !== this.lifecycleGeneration) return; + this.publish( + Object.freeze({ + status: "error", + settings: this.snapshot.settings, + error: toError(cause), + }), + ); + } + } + + async update(partial: AppSettingsUpdate): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.settingsRepository.update(partial); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + async resetPreferences(): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.settingsRepository.resetPreferences(); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + async setActiveDrill(id: string | null): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.drillRepository.setActiveDrill(id); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + async setSelectedDrillPage(id: string | null): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.drillRepository.setSelectedDrillPage(id); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + async reload(): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.settingsRepository.load(); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + getDrillRepository() { + return this.requireStorage().drillRepository; + } + + async dispose(): Promise { + this.lifecycleGeneration += 1; + const storage = this.storage; + this.storage = undefined; + if (storage) await storage.close(); + } + + private async enqueue( + operation: (storage: OpenMobileRepositoriesResult) => Promise, + ): Promise { + const storage = this.requireStorage(); + const result = this.writeQueue.then(() => operation(storage)); + this.writeQueue = result.then( + () => undefined, + () => undefined, + ); + return await result; + } + + private requireStorage(): OpenMobileRepositoriesResult { + if (!this.storage || this.snapshot.status !== "ready") { + throw new Error("App settings storage is not ready."); + } + return this.storage; + } + + private publish(snapshot: AppSettingsStoreSnapshot): void { + if (this.snapshot === snapshot) return; + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } +} + +const AppSettingsStoreContext = React.createContext( + null, +); + +export function AppSettingsProvider({ + children, + store: injectedStore, +}: { + children: React.ReactNode; + store?: AppSettingsStore; +}) { + const [ownedStore] = React.useState(() => new AppSettingsStore()); + const store = injectedStore ?? ownedStore; + + React.useEffect(() => { + void store.initialize(); + return () => void store.dispose(); + }, [store]); + + return ( + + {children} + + ); +} + +export function useAppSettingsStore(): AppSettingsStore { + const store = React.useContext(AppSettingsStoreContext); + if (!store) { + throw new Error( + "useAppSettingsStore must be used inside AppSettingsProvider.", + ); + } + return store; +} + +export function useAppSettingsSnapshot(): AppSettingsStoreSnapshot { + const store = useAppSettingsStore(); + return React.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/state/field-session-store.ts b/apps/mobile/src/state/field-session-store.ts new file mode 100644 index 00000000..de89c0bb --- /dev/null +++ b/apps/mobile/src/state/field-session-store.ts @@ -0,0 +1,40 @@ +import React from "react"; +import type { AppSettings } from "@eight2five/mobile/settings"; + +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "./app-settings-store"; + +export interface FieldSessionSelection { + readonly activeDrillId: string | null; + readonly selectedDrillPageId: string | null; +} + +export function selectFieldSession( + settings: AppSettings, +): FieldSessionSelection { + return { + activeDrillId: settings.activeDrillId, + selectedDrillPageId: settings.selectedDrillPageId, + }; +} + +/** Persisted field-session selection facade used by Field and Drill screens. */ +export function useFieldSession() { + const store = useAppSettingsStore(); + const { status, settings } = useAppSettingsSnapshot(); + const selection = selectFieldSession(settings); + + return React.useMemo( + () => ({ + status, + activeDrillId: selection.activeDrillId, + selectedDrillPageId: selection.selectedDrillPageId, + setActiveDrill: (id: string | null) => store.setActiveDrill(id), + setSelectedDrillPage: (id: string | null) => + store.setSelectedDrillPage(id), + }), + [selection.activeDrillId, selection.selectedDrillPageId, status, store], + ); +} diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 914d10ed..f0563864 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -8,7 +8,9 @@ ".": "./src/index.ts", "./pans-manager": "./src/pans-manager/index.ts", "./field": "./src/field/index.ts", - "./drill": "./src/drill/index.ts" + "./drill": "./src/drill/index.ts", + "./settings": "./src/settings/index.ts", + "./storage": "./src/storage/index.ts" }, "files": [ "src" diff --git a/packages/mobile/src/drill/SqliteDrillRepository.ts b/packages/mobile/src/drill/SqliteDrillRepository.ts new file mode 100644 index 00000000..3fd1e023 --- /dev/null +++ b/packages/mobile/src/drill/SqliteDrillRepository.ts @@ -0,0 +1,700 @@ +import type { SQLiteDatabase } from "expo-sqlite"; +import { assertFiniteFieldPoint, type FieldPoint } from "../field/types"; +import { + APP_SETTINGS_TABLE, + DRILL_PAGES_TABLE, + DRILLS_TABLE, +} from "../storage/mobileDatabase"; +import { SqliteSettingsRepository } from "../settings/SqliteSettingsRepository"; +import type { AppSettings } from "../settings/types"; +import type { Drill, DrillPage } from "./types"; + +type SqlValue = string | number | null; +type Row = Record; + +export interface CreateDrillInput { + readonly id?: string; + readonly name: string; + readonly createdAt?: number; + readonly updatedAt?: number; +} + +export interface CreateDrillPageDetails { + readonly id?: string; + readonly label: string; + readonly countsFromPrevious?: number; + readonly position: FieldPoint; +} + +export interface CreateDrillPageInput extends CreateDrillPageDetails { + readonly drillId: string; +} + +export interface UpdateDrillPageInput { + readonly label?: string; + readonly countsFromPrevious?: number; + readonly position?: FieldPoint; +} + +export interface DrillRepositoryFactories { + readonly idFactory?: () => string; + readonly timeFactory?: () => number; +} + +export interface DrillRepository { + listDrills(): Promise; + getDrill(id: string): Promise; + createDrill(input: CreateDrillInput | string): Promise; + renameDrill(id: string, name: string, updatedAt?: number): Promise; + deleteDrill(id: string): Promise; + setActiveDrill(id: string | null): Promise; + + listPages(drillId: string): Promise; + getPage(id: string): Promise; + createPage(input: CreateDrillPageInput): Promise; + updatePage(id: string, input: UpdateDrillPageInput): Promise; + deletePage(id: string): Promise; + insertPage( + drillId: string, + ordinal: number, + details: CreateDrillPageDetails, + ): Promise; + reorderPages( + drillId: string, + orderedPageIds: readonly (string | { readonly id: string })[], + ): Promise; + setSelectedDrillPage(id: string | null): Promise; +} + +export type DrillRepositoryErrorCode = + | "DRILL_NOT_FOUND" + | "PAGE_NOT_FOUND" + | "INVALID_INPUT" + | "INVALID_PAGE_ORDER" + | "INVALID_SELECTION"; + +export class DrillRepositoryError extends Error { + readonly code: DrillRepositoryErrorCode; + + constructor(code: DrillRepositoryErrorCode, message: string) { + super(message); + this.name = "DrillRepositoryError"; + this.code = code; + } +} + +/** + * SQLite-backed drill storage. All values crossing this boundary are + * validated before they are bound to SQL, and every multi-row ordinal change + * is enclosed in one SQLite transaction. + * + * As with the settings repository, ordinary parameterized `runAsync` is used + * instead of hand-managed prepared statements. Expo SQLite prepares, + * executes, and finalizes each parameterized run for us. + */ +export class SqliteDrillRepository implements DrillRepository { + private readonly idFactory: () => string; + private readonly timeFactory: () => number; + private readonly settingsRepository: SqliteSettingsRepository; + + constructor( + private readonly db: SQLiteDatabase, + factories: DrillRepositoryFactories = {}, + ) { + this.idFactory = factories.idFactory ?? defaultIdFactory; + this.timeFactory = factories.timeFactory ?? (() => Date.now()); + this.settingsRepository = new SqliteSettingsRepository(db); + } + + async listDrills(): Promise { + const rows = await this.db.getAllAsync( + `SELECT id, name, created_at, updated_at + FROM ${DRILLS_TABLE} + ORDER BY created_at ASC, id ASC`, + ); + return rows.map(toDrill); + } + + async getDrill(id: string): Promise { + const drillId = assertId(id, "Drill id"); + const row = await this.db.getFirstAsync( + `SELECT id, name, created_at, updated_at + FROM ${DRILLS_TABLE} + WHERE id = ?`, + [drillId], + ); + return row ? toDrill(row) : undefined; + } + + async createDrill(inputOrName: CreateDrillInput | string): Promise { + const input: CreateDrillInput = + typeof inputOrName === "string" ? { name: inputOrName } : inputOrName; + const name = assertText(input.name, "Drill name"); + const generatedAt = this.timeFactory(); + const created = assertTimestamp( + input.createdAt ?? generatedAt, + "Drill createdAt", + ); + const updated = assertTimestamp( + input.updatedAt ?? created, + "Drill updatedAt", + ); + const id = assertId(input.id ?? this.idFactory(), "Drill id"); + + await this.db.runAsync( + `INSERT INTO ${DRILLS_TABLE} + (id, name, created_at, updated_at) + VALUES (?, ?, ?, ?)`, + [id, name, created, updated], + ); + return requireValue(await this.getDrill(id), "drill", id); + } + + async renameDrill( + idValue: string, + name: string, + updatedAt?: number, + ): Promise { + const id = assertId(idValue, "Drill id"); + const nextName = assertText(name, "Drill name"); + const nextUpdatedAt = assertTimestamp( + updatedAt ?? this.timeFactory(), + "Drill updatedAt", + ); + await this.requireDrill(id); + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, updated_at = ? + WHERE id = ?`, + [nextName, nextUpdatedAt, id], + ); + return requireValue(await this.getDrill(id), "drill", id); + } + + async deleteDrill(id: string): Promise { + const drillId = assertId(id, "Drill id"); + await this.db.withTransactionAsync(async () => { + // Foreign keys clear app_settings pointers and cascade drill pages. + await this.db.runAsync(`DELETE FROM ${DRILLS_TABLE} WHERE id = ?`, [ + drillId, + ]); + }); + } + + async setActiveDrill(id: string | null): Promise { + const activeDrillId = nullableId(id, "Active drill id"); + await this.db.withTransactionAsync(async () => { + if (activeDrillId !== null) await this.requireDrill(activeDrillId); + await this.ensureSettingsRow(); + const current = await this.db.getFirstAsync<{ + active_drill_id: SqlValue | undefined; + }>( + `SELECT active_drill_id + FROM ${APP_SETTINGS_TABLE} + WHERE singleton_id = ?`, + [1], + ); + const currentActive = nullableIdFromSql(current?.active_drill_id); + if (currentActive === activeDrillId && activeDrillId !== null) { + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET active_drill_id = ? + WHERE singleton_id = ?`, + [activeDrillId, 1], + ); + return; + } + // Changing the active drill, including clearing it, clears the page + // selection in the same transaction as the active pointer update. + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET active_drill_id = ?, selected_drill_page_id = NULL + WHERE singleton_id = ?`, + [activeDrillId, 1], + ); + }); + return await this.settingsRepository.load(); + } + + async listPages(drillId: string): Promise { + const parentId = assertId(drillId, "Drill id"); + const rows = await this.db.getAllAsync( + `SELECT id, drill_id, ordinal, label, counts_from_previous, + x_meters, y_meters + FROM ${DRILL_PAGES_TABLE} + WHERE drill_id = ? + ORDER BY ordinal ASC, id ASC`, + [parentId], + ); + return rows.map(toPage); + } + + async getPage(id: string): Promise { + const pageId = assertId(id, "Drill page id"); + const row = await this.db.getFirstAsync( + `SELECT id, drill_id, ordinal, label, counts_from_previous, + x_meters, y_meters + FROM ${DRILL_PAGES_TABLE} + WHERE id = ?`, + [pageId], + ); + return row ? toPage(row) : undefined; + } + + async createPage(input: CreateDrillPageInput): Promise { + const normalized = normalizePage(input); + const createdId = assertId( + normalized.id ?? this.idFactory(), + "Drill page id", + ); + + await this.db.withTransactionAsync(async () => { + await this.requireDrill(normalized.drillId); + const count = await this.pageCount(normalized.drillId); + await this.insertPageRow({ + ...normalized, + id: createdId, + ordinal: count, + }); + }); + return requireValue(await this.getPage(createdId), "drill page", createdId); + } + + async updatePage( + pageId: string, + changes: UpdateDrillPageInput, + ): Promise { + const id = assertId(pageId, "Drill page id"); + const current = await this.getPage(id); + if (!current) throw pageNotFound(id); + + const assignments: string[] = []; + const params: (string | number | null)[] = []; + if (changes.label !== undefined) { + assignments.push("label = ?"); + params.push(assertText(changes.label, "Drill page label")); + } + if (changes.countsFromPrevious !== undefined) { + assignments.push("counts_from_previous = ?"); + params.push( + assertCount(changes.countsFromPrevious, "countsFromPrevious"), + ); + } + if (changes.position !== undefined) { + const position = assertPosition(changes.position); + assignments.push("x_meters = ?", "y_meters = ?"); + params.push(position.xMeters, position.yMeters); + } + if (!assignments.length) return current; + + params.push(id); + await this.db.runAsync( + `UPDATE ${DRILL_PAGES_TABLE} + SET ${assignments.join(", ")} + WHERE id = ?`, + params, + ); + return requireValue(await this.getPage(id), "drill page", id); + } + + async deletePage(id: string): Promise { + const pageId = assertId(id, "Drill page id"); + await this.db.withTransactionAsync(async () => { + const page = await this.getPage(pageId); + if (!page) return; + await this.db.runAsync(`DELETE FROM ${DRILL_PAGES_TABLE} WHERE id = ?`, [ + pageId, + ]); + // The deleted ordinal is now a gap; moving higher ordinals down cannot + // collide with the rows that remain. + await this.db.runAsync( + `UPDATE ${DRILL_PAGES_TABLE} + SET ordinal = ordinal - 1 + WHERE drill_id = ? AND ordinal > ?`, + [page.drillId, page.ordinal], + ); + }); + } + + async insertPage( + drillId: string, + ordinalValue: number, + details: CreateDrillPageDetails, + ): Promise { + const input: CreateDrillPageInput = { ...details, drillId }; + const normalized = normalizePage(input); + const ordinal = assertOrdinal(ordinalValue, "Page ordinal"); + const id = assertId(normalized.id ?? this.idFactory(), "Drill page id"); + + await this.db.withTransactionAsync(async () => { + await this.requireDrill(normalized.drillId); + const count = await this.pageCount(normalized.drillId); + if (ordinal > count) { + throw new RangeError( + `Page ordinal must be between 0 and ${count} when inserting.`, + ); + } + if (count > 0) + await this.shiftPagesForInsertion(normalized.drillId, count, ordinal); + await this.insertPageRow({ ...normalized, id, ordinal }); + }); + return requireValue(await this.getPage(id), "drill page", id); + } + + async reorderPages( + drillId: string, + orderedPageIds: readonly (string | { readonly id: string })[], + ): Promise { + const parentId = assertId(drillId, "Drill id"); + const ids = orderedPageIds.map((value) => + assertId(typeof value === "string" ? value : value.id, "Drill page id"), + ); + if (new Set(ids).size !== ids.length) { + throw new DrillRepositoryError( + "INVALID_PAGE_ORDER", + "A page may appear only once in a reorder operation.", + ); + } + + await this.db.withTransactionAsync(async () => { + const rows = await this.db.getAllAsync<{ id: string }>( + `SELECT id + FROM ${DRILL_PAGES_TABLE} + WHERE drill_id = ? + ORDER BY ordinal ASC, id ASC`, + [parentId], + ); + const existingIds = rows.map((row) => row.id); + if ( + existingIds.length !== ids.length || + existingIds.some((id) => !ids.includes(id)) + ) { + throw new DrillRepositoryError( + "INVALID_PAGE_ORDER", + "A reorder must contain every page in the drill exactly once.", + ); + } + + if (ids.length > 0) { + const offset = ids.length + 1; + await this.db.runAsync( + `UPDATE ${DRILL_PAGES_TABLE} + SET ordinal = ordinal + ? + WHERE drill_id = ?`, + [offset, parentId], + ); + for (const [ordinal, pageId] of ids.entries()) { + await this.db.runAsync( + `UPDATE ${DRILL_PAGES_TABLE} + SET ordinal = ? + WHERE id = ? AND drill_id = ?`, + [ordinal, pageId, parentId], + ); + } + } + }); + return await this.listPages(parentId); + } + + async setSelectedDrillPage(id: string | null): Promise { + const selectedPageId = nullableId(id, "Selected drill page id"); + await this.db.withTransactionAsync(async () => { + await this.ensureSettingsRow(); + const settings = await this.db.getFirstAsync<{ + active_drill_id: SqlValue | undefined; + }>( + `SELECT active_drill_id + FROM ${APP_SETTINGS_TABLE} + WHERE singleton_id = ?`, + [1], + ); + const activeDrillId = nullableIdFromSql(settings?.active_drill_id); + if (selectedPageId === null) { + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET selected_drill_page_id = NULL + WHERE singleton_id = ?`, + [1], + ); + return; + } + if (activeDrillId === null) { + throw new DrillRepositoryError( + "INVALID_SELECTION", + "A drill page cannot be selected without an active drill.", + ); + } + const page = await this.db.getFirstAsync<{ drill_id: string }>( + `SELECT drill_id + FROM ${DRILL_PAGES_TABLE} + WHERE id = ?`, + [selectedPageId], + ); + if (!page || page.drill_id !== activeDrillId) { + throw new DrillRepositoryError( + "INVALID_SELECTION", + "The selected page must belong to the active drill.", + ); + } + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET selected_drill_page_id = ? + WHERE singleton_id = ?`, + [selectedPageId, 1], + ); + }); + return await this.settingsRepository.load(); + } + + private async requireDrill(id: string): Promise { + const drill = await this.getDrill(id); + if (!drill) throw drillNotFound(id); + return drill; + } + + private async pageCount(drillId: string): Promise { + const row = await this.db.getFirstAsync<{ + page_count: SqlValue | undefined; + }>( + `SELECT COUNT(*) AS page_count + FROM ${DRILL_PAGES_TABLE} + WHERE drill_id = ?`, + [drillId], + ); + const count = Number(row?.page_count ?? 0); + if (!Number.isInteger(count) || count < 0) { + throw new DrillRepositoryError( + "INVALID_INPUT", + "The persisted page count is invalid.", + ); + } + return count; + } + + private async insertPageRow(page: { + readonly id: string; + readonly drillId: string; + readonly ordinal: number; + readonly label: string; + readonly countsFromPrevious: number; + readonly position: FieldPoint; + }): Promise { + await this.db.runAsync( + `INSERT INTO ${DRILL_PAGES_TABLE} + (id, drill_id, ordinal, label, counts_from_previous, x_meters, y_meters) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + page.id, + page.drillId, + page.ordinal, + page.label, + page.countsFromPrevious, + page.position.xMeters, + page.position.yMeters, + ], + ); + return page.id; + } + + private async shiftPagesForInsertion( + drillId: string, + count: number, + insertionOrdinal: number, + ): Promise { + const offset = count + 1; + await this.db.runAsync( + `UPDATE ${DRILL_PAGES_TABLE} + SET ordinal = ordinal + ? + WHERE drill_id = ?`, + [offset, drillId], + ); + // The temporary offset prevents SQLite's unique (drill_id, ordinal) + // constraint from observing an intermediate collision. + await this.db.runAsync( + `UPDATE ${DRILL_PAGES_TABLE} + SET ordinal = CASE + WHEN ordinal >= ? THEN ordinal - ? + 1 + ELSE ordinal - ? + END + WHERE drill_id = ?`, + [offset + insertionOrdinal, offset, offset, drillId], + ); + } + + private async ensureSettingsRow(): Promise { + await this.db.runAsync( + `INSERT OR IGNORE INTO ${APP_SETTINGS_TABLE} (singleton_id) + VALUES (?)`, + [1], + ); + } +} + +function normalizePage(input: CreateDrillPageInput): { + readonly id?: string; + readonly drillId: string; + readonly label: string; + readonly countsFromPrevious: number; + readonly position: FieldPoint; +} { + return { + ...(input.id === undefined + ? {} + : { id: assertId(input.id, "Drill page id") }), + drillId: assertId(input.drillId, "Drill id"), + label: assertText(input.label, "Drill page label"), + countsFromPrevious: assertCount( + input.countsFromPrevious ?? 0, + "countsFromPrevious", + ), + position: assertPosition(input.position), + }; +} + +function assertPosition(position: FieldPoint): FieldPoint { + assertFiniteFieldPoint(position, "Drill page position"); + return { xMeters: position.xMeters, yMeters: position.yMeters }; +} + +function assertText(value: unknown, name: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new DrillRepositoryError( + "INVALID_INPUT", + `${name} must be a non-empty string.`, + ); + } + return value.trim(); +} + +function assertId(value: unknown, name: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new DrillRepositoryError( + "INVALID_INPUT", + `${name} must be a non-empty string.`, + ); + } + return value.trim(); +} + +function nullableId(value: string | null, name: string): string | null { + if (value === null) return null; + return assertId(value, name); +} + +function assertTimestamp(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new DrillRepositoryError( + "INVALID_INPUT", + `${name} must be a finite number.`, + ); + } + return value; +} + +function assertCount(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new DrillRepositoryError( + "INVALID_INPUT", + `${name} must be a finite non-negative number.`, + ); + } + return value; +} + +function assertOrdinal(value: unknown, name: string): number { + if ( + typeof value !== "number" || + !Number.isInteger(value) || + !Number.isFinite(value) || + value < 0 + ) { + throw new DrillRepositoryError( + "INVALID_INPUT", + `${name} must be a non-negative integer.`, + ); + } + return value; +} + +function nullableIdFromSql(value: SqlValue | undefined): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function toDrill(row: Row): Drill { + return { + id: rowText(row.id, "drill id"), + name: rowText(row.name, "drill name"), + createdAt: rowNumber(row.created_at, "drill created_at"), + updatedAt: rowNumber(row.updated_at, "drill updated_at"), + }; +} + +function toPage(row: Row): DrillPage { + const xMeters = rowNumber(row.x_meters, "drill page x_meters"); + const yMeters = rowNumber(row.y_meters, "drill page y_meters"); + return { + id: rowText(row.id, "drill page id"), + drillId: rowText(row.drill_id, "drill page drill_id"), + ordinal: rowNumber(row.ordinal, "drill page ordinal"), + label: rowText(row.label, "drill page label"), + countsFromPrevious: rowNumber( + row.counts_from_previous, + "drill page counts_from_previous", + ), + position: { xMeters, yMeters }, + }; +} + +function rowText(value: SqlValue | undefined, name: string): string { + if (typeof value !== "string") { + throw new MobileRowError(`${name} is not a string.`); + } + return value; +} + +function rowNumber(value: SqlValue | undefined, name: string): number { + const number = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : Number.NaN; + if (!Number.isFinite(number)) throw new MobileRowError(`${name} is invalid.`); + return number; +} + +class MobileRowError extends Error { + constructor(message: string) { + super(message); + this.name = "MobileRowError"; + } +} + +function requireValue(value: T | undefined, entity: string, id: string): T { + if (value !== undefined) return value; + throw new MobileRowError(`The persisted ${entity} ${id} could not be read.`); +} + +function drillNotFound(id: string): DrillRepositoryError { + return new DrillRepositoryError( + "DRILL_NOT_FOUND", + `Drill ${id} was not found.`, + ); +} + +function pageNotFound(id: string): DrillRepositoryError { + return new DrillRepositoryError( + "PAGE_NOT_FOUND", + `Drill page ${id} was not found.`, + ); +} + +function defaultIdFactory(): string { + const cryptoApi = globalThis.crypto; + if (cryptoApi?.randomUUID) return cryptoApi.randomUUID(); + return `mobile-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 12)}`; +} diff --git a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts new file mode 100644 index 00000000..a8961331 --- /dev/null +++ b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts @@ -0,0 +1,437 @@ +import type { SQLiteDatabase } from "expo-sqlite"; +import { SqliteDrillRepository } from "../SqliteDrillRepository"; + +describe("SqliteDrillRepository", () => { + test("uses stable factories and deterministic drill/page ordering", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill-1", "drill-2", "page-1", "page-2", "page-3"]; + const times = [20, 10]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => times.shift()!, + }); + + const first = await repository.createDrill("First"); + const second = await repository.createDrill("Second"); + expect(first).toMatchObject({ + id: "drill-1", + createdAt: 20, + updatedAt: 20, + }); + expect(second).toMatchObject({ + id: "drill-2", + createdAt: 10, + updatedAt: 10, + }); + expect((await repository.listDrills()).map(({ id }) => id)).toEqual([ + "drill-2", + "drill-1", + ]); + + const firstPage = await repository.createPage({ + drillId: first.id, + label: "Start", + position: { xMeters: 1, yMeters: 2 }, + }); + const secondPage = await repository.createPage({ + drillId: first.id, + label: "Second", + countsFromPrevious: 2.5, + position: { xMeters: 3, yMeters: 4 }, + }); + expect(firstPage).toMatchObject({ + id: "page-1", + ordinal: 0, + countsFromPrevious: 0, + }); + expect(secondPage).toMatchObject({ id: "page-2", ordinal: 1 }); + expect((await repository.listPages(first.id)).map(({ id }) => id)).toEqual([ + "page-1", + "page-2", + ]); + }); + + test("inserts, reorders, updates, and deletes pages through transactions", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill", "page-a", "page-b", "page-inserted"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drill = await repository.createDrill({ name: "Practice" }); + await repository.createPage({ + drillId: drill.id, + label: "A", + position: { xMeters: 0, yMeters: 0 }, + }); + await repository.createPage({ + drillId: drill.id, + label: "B", + position: { xMeters: 2, yMeters: 2 }, + }); + + await repository.insertPage(drill.id, 1, { + label: "Inserted", + countsFromPrevious: 1, + position: { xMeters: 1, yMeters: 1 }, + }); + expect( + (await repository.listPages(drill.id)).map((page) => [ + page.id, + page.ordinal, + ]), + ).toEqual([ + ["page-a", 0], + ["page-inserted", 1], + ["page-b", 2], + ]); + + await repository.reorderPages(drill.id, [ + "page-b", + "page-a", + "page-inserted", + ]); + expect((await repository.listPages(drill.id)).map(({ id }) => id)).toEqual([ + "page-b", + "page-a", + "page-inserted", + ]); + await repository.updatePage("page-inserted", { + label: "Updated", + position: { xMeters: 9, yMeters: 10 }, + }); + expect(await repository.getPage("page-inserted")).toMatchObject({ + label: "Updated", + position: { xMeters: 9, yMeters: 10 }, + }); + + await repository.deletePage("page-a"); + expect( + (await repository.listPages(drill.id)).map((page) => [ + page.id, + page.ordinal, + ]), + ).toEqual([ + ["page-b", 0], + ["page-inserted", 1], + ]); + expect(fake.database.withTransactionAsync).toHaveBeenCalled(); + }); + + test("persists active and selected pointers, validates selection, and honors FK deletion contracts", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill-1", "drill-2", "page-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const first = await repository.createDrill("First"); + const second = await repository.createDrill("Second"); + const page = await repository.createPage({ + drillId: first.id, + label: "Page", + position: { xMeters: 0, yMeters: 0 }, + }); + + await repository.setActiveDrill(first.id); + await expect( + repository.setSelectedDrillPage(page.id), + ).resolves.toMatchObject({ + activeDrillId: first.id, + selectedDrillPageId: page.id, + }); + await expect( + repository.setSelectedDrillPage("missing"), + ).rejects.toMatchObject({ code: "INVALID_SELECTION" }); + await expect(repository.setActiveDrill("missing")).rejects.toMatchObject({ + code: "DRILL_NOT_FOUND", + }); + + await expect(repository.setActiveDrill(second.id)).resolves.toMatchObject({ + activeDrillId: second.id, + selectedDrillPageId: null, + }); + await expect( + repository.setSelectedDrillPage(page.id), + ).rejects.toMatchObject({ code: "INVALID_SELECTION" }); + + await repository.setActiveDrill(first.id); + await repository.setSelectedDrillPage(page.id); + await repository.deletePage(page.id); + expect(fake.settings.selected_drill_page_id).toBeNull(); + + await repository.setActiveDrill(first.id); + await repository.deleteDrill(first.id); + expect(fake.settings.active_drill_id).toBeNull(); + expect(fake.pages.size).toBe(0); + }); + + test("rejects malformed names, labels, counts, and coordinates", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => "drill", + timeFactory: () => 1, + }); + const drill = await repository.createDrill("Drill"); + + await expect(repository.createDrill(" ")).rejects.toMatchObject({ + code: "INVALID_INPUT", + }); + await expect( + repository.createPage({ + drillId: drill.id, + label: "Page", + countsFromPrevious: -1, + position: { xMeters: 0, yMeters: 0 }, + }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + await expect( + repository.createPage({ + drillId: drill.id, + label: "Page", + position: { xMeters: Number.NaN, yMeters: 0 }, + }), + ).rejects.toThrow("xMeters"); + }); +}); + +type FakeDrillRow = { + id: string; + name: string; + created_at: number; + updated_at: number; +}; + +type FakePageRow = { + id: string; + drill_id: string; + ordinal: number; + label: string; + counts_from_previous: number; + x_meters: number; + y_meters: number; +}; + +class DrillFakeDatabase { + readonly drills = new Map(); + readonly pages = new Map(); + readonly settings = { + drill_features_enabled: 1, + drill_terminology: "pages", + field_perspective: "director", + transition_metric_mode: "step-size", + guidance_enabled: 1, + developer_mode_enabled: 0, + show_cached_anchor_geometry: 0, + show_comfortable_anchor_range: 0, + comfortable_anchor_range_meters: 20, + active_drill_id: null as string | null, + selected_drill_page_id: null as string | null, + }; + readonly database: SQLiteDatabase & { + withTransactionAsync: jest.Mock; + }; + + constructor() { + const database = { + execAsync: jest.fn(async () => undefined), + getFirstAsync: jest.fn((sql: string, params: unknown[] = []) => + this.getFirst(sql, params), + ), + getAllAsync: jest.fn((sql: string, params: unknown[] = []) => + this.getAll(sql, params), + ), + runAsync: jest.fn((sql: string, params: unknown[] = []) => + this.run(sql, params), + ), + withTransactionAsync: jest.fn(async (task: () => Promise) => { + const drills = new Map(this.drills); + const pages = new Map(this.pages); + const settings = { ...this.settings }; + try { + await task(); + } catch (error) { + this.drills.clear(); + this.pages.clear(); + for (const [id, row] of drills) this.drills.set(id, row); + for (const [id, row] of pages) this.pages.set(id, row); + Object.assign(this.settings, settings); + throw error; + } + }), + }; + this.database = database as unknown as SQLiteDatabase & { + withTransactionAsync: jest.Mock; + }; + } + + private async getFirst(sql: string, params: unknown[]): Promise { + if (sql.includes("COUNT(*) AS page_count")) { + return { + page_count: [...this.pages.values()].filter( + (page) => page.drill_id === params[0], + ).length, + }; + } + if (sql.includes("FROM drills")) { + const row = this.drills.get(String(params[0])); + return row ? { ...row } : null; + } + if (sql.includes("FROM drill_pages") && sql.includes("drill_id")) { + const row = this.pages.get(String(params[0])); + return row ? { ...row } : null; + } + if (sql.includes("FROM app_settings")) return { ...this.settings }; + return null; + } + + private async getAll(sql: string, params: unknown[]): Promise { + if (sql.includes("FROM drills")) { + return [...this.drills.values()] + .sort( + (left, right) => + left.created_at - right.created_at || + left.id.localeCompare(right.id), + ) + .map((row) => ({ ...row })); + } + if (sql.includes("FROM drill_pages")) { + return [...this.pages.values()] + .filter((page) => page.drill_id === params[0]) + .sort( + (left, right) => + left.ordinal - right.ordinal || left.id.localeCompare(right.id), + ) + .map((row) => + sql.includes("SELECT id\n") ? { id: row.id } : { ...row }, + ); + } + return []; + } + + private async run(sql: string, params: unknown[]): Promise { + if (sql.includes("INSERT INTO drills")) { + const [id, name, createdAt, updatedAt] = params as [ + string, + string, + number, + number, + ]; + this.drills.set(id, { + id, + name, + created_at: createdAt, + updated_at: updatedAt, + }); + } else if (sql.includes("INSERT INTO drill_pages")) { + const [id, drillId, ordinal, label, counts, xMeters, yMeters] = + params as [string, string, number, string, number, number, number]; + this.pages.set(id, { + id, + drill_id: drillId, + ordinal, + label, + counts_from_previous: counts, + x_meters: xMeters, + y_meters: yMeters, + }); + } else if (sql.includes("INSERT OR IGNORE INTO app_settings")) { + // The singleton already exists in this fake. + } else if (sql.includes("DELETE FROM drills")) { + const id = String(params[0]); + this.drills.delete(id); + for (const [pageId, page] of this.pages) { + if (page.drill_id === id) { + this.pages.delete(pageId); + if (this.settings.selected_drill_page_id === pageId) { + this.settings.selected_drill_page_id = null; + } + } + } + if (this.settings.active_drill_id === id) + this.settings.active_drill_id = null; + } else if (sql.includes("DELETE FROM drill_pages")) { + const id = String(params[0]); + this.pages.delete(id); + if (this.settings.selected_drill_page_id === id) { + this.settings.selected_drill_page_id = null; + } + } else if (sql.includes("UPDATE drills")) { + const [name, updatedAt, id] = params as [string, number, string]; + const row = this.drills.get(id); + if (row) this.drills.set(id, { ...row, name, updated_at: updatedAt }); + } else if (sql.includes("UPDATE app_settings")) { + this.updateSettings(sql, params); + } else if (sql.includes("UPDATE drill_pages")) { + this.updatePages(sql, params); + } + return { lastInsertRowId: 1, changes: 1 }; + } + + private updateSettings(sql: string, params: unknown[]): void { + if (sql.includes("active_drill_id = ?")) { + this.settings.active_drill_id = params[0] as string | null; + } + if (sql.includes("selected_drill_page_id = NULL")) { + this.settings.selected_drill_page_id = null; + } else if (sql.includes("selected_drill_page_id = ?")) { + this.settings.selected_drill_page_id = params[0] as string; + } + } + + private updatePages(sql: string, params: unknown[]): void { + if (sql.includes("ordinal = ordinal + ?")) { + const [offset, drillId] = params as [number, string]; + for (const page of this.pages.values()) { + if (page.drill_id === drillId) page.ordinal += offset; + } + return; + } + if (sql.includes("ordinal = CASE")) { + const [threshold, offset, , drillId] = params as [ + number, + number, + number, + string, + ]; + for (const page of this.pages.values()) { + if (page.drill_id === drillId) { + page.ordinal = + page.ordinal >= threshold + ? page.ordinal - offset + 1 + : page.ordinal - offset; + } + } + return; + } + if (sql.includes("ordinal = ordinal - 1")) { + const [drillId, ordinal] = params as [string, number]; + for (const page of this.pages.values()) { + if (page.drill_id === drillId && page.ordinal > ordinal) + page.ordinal -= 1; + } + return; + } + if (sql.includes("SET ordinal = ?")) { + const [ordinal, id] = params as [number, string, string]; + const page = this.pages.get(id); + if (page) page.ordinal = ordinal; + return; + } + const id = String(params[params.length - 1]); + const page = this.pages.get(id); + if (!page) return; + if (sql.includes("label = ?")) page.label = String(params[0]); + if (sql.includes("counts_from_previous = ?")) { + page.counts_from_previous = Number( + sql.includes("label = ?") ? params[1] : params[0], + ); + } + if (sql.includes("x_meters = ?")) { + const offset = sql.includes("label = ?") ? 1 : 0; + const countOffset = sql.includes("counts_from_previous = ?") ? 1 : 0; + page.x_meters = Number(params[offset + countOffset]); + page.y_meters = Number(params[offset + countOffset + 1]); + } + } +} diff --git a/packages/mobile/src/drill/index.ts b/packages/mobile/src/drill/index.ts index 3a3f92a2..93ce2149 100644 --- a/packages/mobile/src/drill/index.ts +++ b/packages/mobile/src/drill/index.ts @@ -1,3 +1,4 @@ export * from "./types"; export * from "./terminology"; export * from "./analysis"; +export * from "./SqliteDrillRepository"; diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index fe464732..65710a1a 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -33,3 +33,5 @@ export * from "./field/template"; export * from "./field/marching"; export * from "./field/guidance"; export * from "./drill"; +export * from "./settings"; +export * from "./storage"; diff --git a/packages/mobile/src/mobile-repositories.ts b/packages/mobile/src/mobile-repositories.ts new file mode 100644 index 00000000..cea3621e --- /dev/null +++ b/packages/mobile/src/mobile-repositories.ts @@ -0,0 +1,47 @@ +import type { SQLiteDatabase } from "expo-sqlite"; +import { + MOBILE_DB_NAME, + migrateMobileDatabase, +} from "./storage/mobileDatabase"; +import { SqliteDrillRepository } from "./drill/SqliteDrillRepository"; +import { SqliteSettingsRepository } from "./settings/SqliteSettingsRepository"; + +export interface OpenMobileRepositoriesResult { + readonly drillRepository: SqliteDrillRepository; + readonly settingsRepository: SqliteSettingsRepository; + close(): Promise; +} + +/** + * Open the app-side repositories over one database connection. + * + * `expo-sqlite` is imported lazily so consumers of the pure field and drill + * helpers do not load native SQLite at module evaluation time. Migration is + * owned here and runs before either repository is exposed. + */ +export async function openMobileRepositories( + databaseName = MOBILE_DB_NAME, +): Promise { + const { openDatabaseAsync } = await import("expo-sqlite"); + const database = await openDatabaseAsync(databaseName); + try { + await migrateMobileDatabase(database); + } catch (cause) { + await closeQuietly(database); + throw cause; + } + + return { + drillRepository: new SqliteDrillRepository(database), + settingsRepository: new SqliteSettingsRepository(database), + close: async () => await database.closeAsync(), + }; +} + +async function closeQuietly(database: SQLiteDatabase): Promise { + try { + await database.closeAsync(); + } catch { + // Preserve the migration/opening error rather than masking it with close. + } +} diff --git a/packages/mobile/src/settings/SqliteSettingsRepository.ts b/packages/mobile/src/settings/SqliteSettingsRepository.ts new file mode 100644 index 00000000..d7c74b0c --- /dev/null +++ b/packages/mobile/src/settings/SqliteSettingsRepository.ts @@ -0,0 +1,200 @@ +import type { SQLiteDatabase } from "expo-sqlite"; +import { APP_SETTINGS_TABLE } from "../storage/mobileDatabase"; +import type { + AppSettings, + AppSettingsRepository, + AppSettingsUpdate, +} from "./types"; +import { DEFAULT_APP_SETTINGS, normalizeAppSettings } from "./types"; + +type SqlValue = string | number | null; +type AppSettingsRow = Record; + +/** + * SQLite implementation for the singleton app settings row. + * + * Parameterized `runAsync` calls are intentionally used directly. Expo SQLite + * documents `runAsync` as a prepare/execute/finalize convenience wrapper, so + * an explicit prepared-statement loop would add complexity without changing + * the safety or performance contract needed by these small writes. + */ +export class SqliteSettingsRepository implements AppSettingsRepository { + constructor(private readonly db: SQLiteDatabase) {} + + async load(): Promise { + const row = await this.readRow(); + if (!row) { + await this.write(DEFAULT_APP_SETTINGS); + return normalizeAppSettings(DEFAULT_APP_SETTINGS); + } + + const settings = fromRow(row); + if (!isCanonicalRow(row, settings)) await this.write(settings); + return settings; + } + + async update(partial: AppSettingsUpdate): Promise { + const current = await this.load(); + const next = normalizeAppSettings({ + ...current, + ...(isRecord(partial) ? partial : {}), + }); + await this.write(next); + return next; + } + + async resetPreferences(): Promise { + await this.load(); + // Deliberately omit both selection columns: resetPreferences must not + // overwrite activeDrillId or selectedDrillPageId, even if another caller + // changes a selection between the initial load and this write. + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET drill_features_enabled = ?, + drill_terminology = ?, + field_perspective = ?, + transition_metric_mode = ?, + guidance_enabled = ?, + developer_mode_enabled = ?, + show_cached_anchor_geometry = ?, + show_comfortable_anchor_range = ?, + comfortable_anchor_range_meters = ? + WHERE singleton_id = ?`, + [ + boolToSql(DEFAULT_APP_SETTINGS.drillFeaturesEnabled), + DEFAULT_APP_SETTINGS.drillTerminology, + DEFAULT_APP_SETTINGS.fieldPerspective, + DEFAULT_APP_SETTINGS.transitionMetricMode, + boolToSql(DEFAULT_APP_SETTINGS.guidanceEnabled), + boolToSql(DEFAULT_APP_SETTINGS.developerModeEnabled), + boolToSql(DEFAULT_APP_SETTINGS.showCachedAnchorGeometry), + boolToSql(DEFAULT_APP_SETTINGS.showComfortableAnchorRange), + DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, + 1, + ], + ); + return await this.load(); + } + + private async readRow(): Promise { + return await this.db.getFirstAsync( + `SELECT + drill_features_enabled, + drill_terminology, + field_perspective, + transition_metric_mode, + guidance_enabled, + developer_mode_enabled, + show_cached_anchor_geometry, + show_comfortable_anchor_range, + comfortable_anchor_range_meters, + active_drill_id, + selected_drill_page_id + FROM ${APP_SETTINGS_TABLE} + WHERE singleton_id = ?`, + [1], + ); + } + + private async write(settings: AppSettings): Promise { + const normalized = normalizeAppSettings(settings); + await this.db.runAsync( + `INSERT INTO ${APP_SETTINGS_TABLE} ( + singleton_id, + drill_features_enabled, + drill_terminology, + field_perspective, + transition_metric_mode, + guidance_enabled, + developer_mode_enabled, + show_cached_anchor_geometry, + show_comfortable_anchor_range, + comfortable_anchor_range_meters, + active_drill_id, + selected_drill_page_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(singleton_id) DO UPDATE SET + drill_features_enabled = excluded.drill_features_enabled, + drill_terminology = excluded.drill_terminology, + field_perspective = excluded.field_perspective, + transition_metric_mode = excluded.transition_metric_mode, + guidance_enabled = excluded.guidance_enabled, + developer_mode_enabled = excluded.developer_mode_enabled, + show_cached_anchor_geometry = excluded.show_cached_anchor_geometry, + show_comfortable_anchor_range = excluded.show_comfortable_anchor_range, + comfortable_anchor_range_meters = excluded.comfortable_anchor_range_meters, + active_drill_id = excluded.active_drill_id, + selected_drill_page_id = excluded.selected_drill_page_id`, + [ + 1, + boolToSql(normalized.drillFeaturesEnabled), + normalized.drillTerminology, + normalized.fieldPerspective, + normalized.transitionMetricMode, + boolToSql(normalized.guidanceEnabled), + boolToSql(normalized.developerModeEnabled), + boolToSql(normalized.showCachedAnchorGeometry), + boolToSql(normalized.showComfortableAnchorRange), + normalized.comfortableAnchorRangeMeters, + normalized.activeDrillId, + normalized.selectedDrillPageId, + ], + ); + } +} + +/** Useful when a caller has a raw SQLite settings row outside the repository. */ +export function normalizeAppSettingsRow(row: unknown): AppSettings { + return fromRow(isRecord(row) ? (row as AppSettingsRow) : {}); +} + +function fromRow(row: AppSettingsRow): AppSettings { + return normalizeAppSettings({ + drillFeaturesEnabled: sqliteBoolean(row.drill_features_enabled), + drillTerminology: row.drill_terminology, + fieldPerspective: row.field_perspective, + transitionMetricMode: row.transition_metric_mode, + guidanceEnabled: sqliteBoolean(row.guidance_enabled), + developerModeEnabled: sqliteBoolean(row.developer_mode_enabled), + showCachedAnchorGeometry: sqliteBoolean(row.show_cached_anchor_geometry), + showComfortableAnchorRange: sqliteBoolean( + row.show_comfortable_anchor_range, + ), + comfortableAnchorRangeMeters: row.comfortable_anchor_range_meters, + activeDrillId: row.active_drill_id, + selectedDrillPageId: row.selected_drill_page_id, + }); +} + +function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { + return ( + row.drill_features_enabled === boolToSql(settings.drillFeaturesEnabled) && + row.drill_terminology === settings.drillTerminology && + row.field_perspective === settings.fieldPerspective && + row.transition_metric_mode === settings.transitionMetricMode && + row.guidance_enabled === boolToSql(settings.guidanceEnabled) && + row.developer_mode_enabled === boolToSql(settings.developerModeEnabled) && + row.show_cached_anchor_geometry === + boolToSql(settings.showCachedAnchorGeometry) && + row.show_comfortable_anchor_range === + boolToSql(settings.showComfortableAnchorRange) && + row.comfortable_anchor_range_meters === + settings.comfortableAnchorRangeMeters && + row.active_drill_id === settings.activeDrillId && + row.selected_drill_page_id === settings.selectedDrillPageId + ); +} + +function sqliteBoolean(value: SqlValue | undefined): unknown { + if (value === 1) return true; + if (value === 0) return false; + return value; +} + +function boolToSql(value: boolean): number { + return value ? 1 : 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts new file mode 100644 index 00000000..182d0bfd --- /dev/null +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -0,0 +1,179 @@ +import type { SQLiteDatabase } from "expo-sqlite"; +import { SqliteSettingsRepository } from "../SqliteSettingsRepository"; +import { + DEFAULT_APP_SETTINGS, + getEffectiveAppSettings, + normalizeAppSettings, +} from "../types"; + +describe("app settings", () => { + test("loads defaults when the singleton row is absent", async () => { + const fake = new SettingsFakeDatabase(null); + const repository = new SqliteSettingsRepository(fake.database); + + await expect(repository.load()).resolves.toEqual(DEFAULT_APP_SETTINGS); + expect(fake.row).toMatchObject({ + drill_features_enabled: 1, + drill_terminology: "pages", + field_perspective: "director", + transition_metric_mode: "step-size", + guidance_enabled: 1, + developer_mode_enabled: 0, + comfortable_anchor_range_meters: 20, + active_drill_id: null, + selected_drill_page_id: null, + }); + }); + + test("normalizes every invalid persisted value and keeps stale overlay flags", async () => { + const fake = new SettingsFakeDatabase({ + drill_features_enabled: 2, + drill_terminology: "unknown", + field_perspective: "unknown", + transition_metric_mode: "unknown", + guidance_enabled: "yes", + developer_mode_enabled: 0, + show_cached_anchor_geometry: 1, + show_comfortable_anchor_range: 1, + comfortable_anchor_range_meters: Number.NaN, + active_drill_id: 17, + selected_drill_page_id: "", + }); + const repository = new SqliteSettingsRepository(fake.database); + + const loaded = await repository.load(); + + expect(loaded).toEqual({ + ...DEFAULT_APP_SETTINGS, + showCachedAnchorGeometry: true, + showComfortableAnchorRange: true, + }); + expect(fake.database.runAsync).toHaveBeenCalled(); + expect(getEffectiveAppSettings(loaded)).toMatchObject({ + developerModeEnabled: false, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: false, + }); + }); + + test("updates only supplied fields and normalizes invalid updates", async () => { + const fake = new SettingsFakeDatabase({ + drill_features_enabled: 1, + drill_terminology: "pages", + field_perspective: "director", + transition_metric_mode: "step-size", + guidance_enabled: 1, + developer_mode_enabled: 1, + show_cached_anchor_geometry: 1, + show_comfortable_anchor_range: 1, + comfortable_anchor_range_meters: 30, + active_drill_id: "drill-1", + selected_drill_page_id: "page-1", + }); + const repository = new SqliteSettingsRepository(fake.database); + + const updated = await repository.update({ + drillTerminology: "not-a-value" as never, + comfortableAnchorRangeMeters: -1, + }); + + expect(updated).toMatchObject({ + drillFeaturesEnabled: true, + drillTerminology: "pages", + comfortableAnchorRangeMeters: 20, + activeDrillId: "drill-1", + selectedDrillPageId: "page-1", + }); + expect(updated.developerModeEnabled).toBe(true); + expect(updated.showCachedAnchorGeometry).toBe(true); + expect(updated.showComfortableAnchorRange).toBe(true); + }); + + test("resetPreferences restores nine preference fields but preserves selection", async () => { + const fake = new SettingsFakeDatabase({ + drill_features_enabled: 0, + drill_terminology: "sets", + field_perspective: "performer", + transition_metric_mode: "crossing-counts", + guidance_enabled: 0, + developer_mode_enabled: 1, + show_cached_anchor_geometry: 1, + show_comfortable_anchor_range: 1, + comfortable_anchor_range_meters: 7, + active_drill_id: "drill-1", + selected_drill_page_id: "page-2", + }); + const repository = new SqliteSettingsRepository(fake.database); + + const reset = await repository.resetPreferences(); + + expect(reset).toEqual({ + ...DEFAULT_APP_SETTINGS, + activeDrillId: "drill-1", + selectedDrillPageId: "page-2", + }); + }); + + test("the pure normalizer treats malformed input as defaults", () => { + expect( + normalizeAppSettings({ + drillFeaturesEnabled: "true", + guidanceEnabled: null, + comfortableAnchorRangeMeters: 0, + activeDrillId: " ", + }), + ).toEqual(DEFAULT_APP_SETTINGS); + }); + + test("clears an impossible selected page when no drill is active", () => { + expect( + normalizeAppSettings({ + activeDrillId: null, + selectedDrillPageId: "page-1", + }).selectedDrillPageId, + ).toBeNull(); + }); +}); + +class SettingsFakeDatabase { + readonly database: SQLiteDatabase; + row: Record | null; + + constructor(row: Record | null) { + this.row = row; + this.database = { + getFirstAsync: jest.fn(async () => (this.row ? { ...this.row } : null)), + runAsync: jest.fn(async (sql: string, params: unknown[]) => { + if (sql.includes("UPDATE app_settings")) { + this.row = { + ...(this.row ?? {}), + drill_features_enabled: params[0], + drill_terminology: params[1], + field_perspective: params[2], + transition_metric_mode: params[3], + guidance_enabled: params[4], + developer_mode_enabled: params[5], + show_cached_anchor_geometry: params[6], + show_comfortable_anchor_range: params[7], + comfortable_anchor_range_meters: params[8], + }; + } else { + this.row = { + drill_features_enabled: params[1], + drill_terminology: params[2], + field_perspective: params[3], + transition_metric_mode: params[4], + guidance_enabled: params[5], + developer_mode_enabled: params[6], + show_cached_anchor_geometry: params[7], + show_comfortable_anchor_range: params[8], + comfortable_anchor_range_meters: params[9], + active_drill_id: params[10], + selected_drill_page_id: params[11], + }; + } + return { lastInsertRowId: 1, changes: 1 }; + }), + } as unknown as SQLiteDatabase; + } +} diff --git a/packages/mobile/src/settings/index.ts b/packages/mobile/src/settings/index.ts new file mode 100644 index 00000000..d741cde7 --- /dev/null +++ b/packages/mobile/src/settings/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./SqliteSettingsRepository"; diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts new file mode 100644 index 00000000..2e855bc8 --- /dev/null +++ b/packages/mobile/src/settings/types.ts @@ -0,0 +1,183 @@ +import type { DrillTerminology } from "../drill/terminology"; + +export type FieldPerspective = "director" | "performer"; +export type TransitionMetricMode = "step-size" | "crossing-counts"; + +/** + * App preferences and the two persisted selection pointers. + * + * The selection pointers live in the same singleton row as preferences so a + * drill screen can restore its place without introducing another storage + * mechanism. They are intentionally not part of resetPreferences(). + */ +export interface AppSettings { + readonly drillFeaturesEnabled: boolean; + readonly drillTerminology: DrillTerminology; + readonly fieldPerspective: FieldPerspective; + readonly transitionMetricMode: TransitionMetricMode; + readonly guidanceEnabled: boolean; + readonly developerModeEnabled: boolean; + readonly showCachedAnchorGeometry: boolean; + readonly showComfortableAnchorRange: boolean; + readonly comfortableAnchorRangeMeters: number; + readonly activeDrillId: string | null; + readonly selectedDrillPageId: string | null; +} + +export type AppSettingsUpdate = Partial; + +export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ + drillFeaturesEnabled: true, + drillTerminology: "pages", + fieldPerspective: "director", + transitionMetricMode: "step-size", + guidanceEnabled: true, + developerModeEnabled: false, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: false, + comfortableAnchorRangeMeters: 20, + activeDrillId: null, + selectedDrillPageId: null, +}); + +/** The preferences reset by resetPreferences, in their public contract order. */ +export const APP_PREFERENCE_KEYS = Object.freeze([ + "drillFeaturesEnabled", + "drillTerminology", + "fieldPerspective", + "transitionMetricMode", + "guidanceEnabled", + "developerModeEnabled", + "showCachedAnchorGeometry", + "showComfortableAnchorRange", + "comfortableAnchorRangeMeters", +] as const satisfies readonly (keyof AppSettings)[]); + +export type AppPreferenceKey = (typeof APP_PREFERENCE_KEYS)[number]; + +export interface AppSettingsRepository { + load(): Promise; + update(partial: AppSettingsUpdate): Promise; + resetPreferences(): Promise; +} + +/** + * Normalize values at every storage boundary. Invalid values fall back to the + * field default rather than leaking malformed persisted data to a caller. + */ +export function normalizeAppSettings(value?: unknown): AppSettings { + const candidate = isRecord(value) ? value : {}; + const activeDrillId = nullableIdOrNull(candidate.activeDrillId); + return { + drillFeaturesEnabled: booleanOrDefault( + candidate.drillFeaturesEnabled, + DEFAULT_APP_SETTINGS.drillFeaturesEnabled, + ), + drillTerminology: + candidate.drillTerminology === "pages" || + candidate.drillTerminology === "sets" + ? candidate.drillTerminology + : DEFAULT_APP_SETTINGS.drillTerminology, + fieldPerspective: + candidate.fieldPerspective === "director" || + candidate.fieldPerspective === "performer" + ? candidate.fieldPerspective + : DEFAULT_APP_SETTINGS.fieldPerspective, + transitionMetricMode: + candidate.transitionMetricMode === "step-size" || + candidate.transitionMetricMode === "crossing-counts" + ? candidate.transitionMetricMode + : DEFAULT_APP_SETTINGS.transitionMetricMode, + guidanceEnabled: booleanOrDefault( + candidate.guidanceEnabled, + DEFAULT_APP_SETTINGS.guidanceEnabled, + ), + developerModeEnabled: booleanOrDefault( + candidate.developerModeEnabled, + DEFAULT_APP_SETTINGS.developerModeEnabled, + ), + showCachedAnchorGeometry: booleanOrDefault( + candidate.showCachedAnchorGeometry, + DEFAULT_APP_SETTINGS.showCachedAnchorGeometry, + ), + showComfortableAnchorRange: booleanOrDefault( + candidate.showComfortableAnchorRange, + DEFAULT_APP_SETTINGS.showComfortableAnchorRange, + ), + comfortableAnchorRangeMeters: positiveFiniteOrDefault( + candidate.comfortableAnchorRangeMeters, + DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, + ), + activeDrillId, + selectedDrillPageId: + activeDrillId === null + ? null + : nullableIdOrNull(candidate.selectedDrillPageId), + }; +} + +/** + * Return settings as they may be used by UI. Developer overlay preferences + * remain persisted separately, but are ineffective while developer mode is + * disabled. + */ +export function getEffectiveAppSettings(value: AppSettings): AppSettings { + const normalized = normalizeAppSettings(value); + if (normalized.developerModeEnabled) return normalized; + return { + ...normalized, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: false, + }; +} + +/** Alias with selector-oriented naming for store consumers. */ +export const selectEffectiveSettings = getEffectiveAppSettings; +export const getEffectiveSettings = getEffectiveAppSettings; +export const selectEffectiveAppSettings = getEffectiveAppSettings; + +export interface EffectiveDeveloperOverlaySettings { + readonly showCachedAnchorGeometry: boolean; + readonly showComfortableAnchorRange: boolean; +} + +export function getEffectiveDeveloperOverlaySettings( + value: AppSettings, +): EffectiveDeveloperOverlaySettings { + const settings = getEffectiveAppSettings(value); + return { + showCachedAnchorGeometry: settings.showCachedAnchorGeometry, + showComfortableAnchorRange: settings.showComfortableAnchorRange, + }; +} + +export const selectEffectiveDeveloperOverlaySettings = + getEffectiveDeveloperOverlaySettings; + +export function selectShowCachedAnchorGeometry(value: AppSettings): boolean { + return getEffectiveAppSettings(value).showCachedAnchorGeometry; +} + +export function selectShowComfortableAnchorRange(value: AppSettings): boolean { + return getEffectiveAppSettings(value).showComfortableAnchorRange; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function booleanOrDefault(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function positiveFiniteOrDefault(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : fallback; +} + +function nullableIdOrNull(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts new file mode 100644 index 00000000..c72b7301 --- /dev/null +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -0,0 +1,70 @@ +import type { SQLiteDatabase } from "expo-sqlite"; +import { + migrateMobileDatabase, + MOBILE_DB_NAME, + MOBILE_SCHEMA_VERSION, +} from "../mobileDatabase"; + +describe("mobile app SQLite migration", () => { + test("creates the relational schema, defaults, indexes, WAL, and foreign keys", async () => { + const executed: string[] = []; + const database = fakeDatabase(0, executed); + + await migrateMobileDatabase(database); + + const sql = executed.join("\n"); + expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); + expect(sql).toContain("PRAGMA journal_mode = WAL"); + expect(sql).toContain("PRAGMA foreign_keys = ON"); + expect(sql).toContain( + "CREATE TABLE IF NOT EXISTS mobile_schema_migrations", + ); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS drills"); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS drill_pages"); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS app_settings"); + expect(sql).toContain("REFERENCES drills(id) ON DELETE CASCADE"); + expect(sql).toContain("REFERENCES drills(id) ON DELETE SET NULL"); + expect(sql).toContain("REFERENCES drill_pages(id) ON DELETE SET NULL"); + expect(sql).toContain("UNIQUE (drill_id, ordinal)"); + expect(sql).toContain("idx_drill_pages_drill"); + expect(sql).toContain("DEFAULT 'pages'"); + expect(sql).toContain("DEFAULT 'director'"); + expect(sql).toContain("DEFAULT 'step-size'"); + expect(sql).toContain("DEFAULT 20"); + expect(sql).toContain(`PRAGMA user_version = ${MOBILE_SCHEMA_VERSION}`); + expect(database.runAsync).toHaveBeenCalledWith( + expect.stringContaining("mobile_schema_migrations"), + [MOBILE_SCHEMA_VERSION, expect.any(Number)], + ); + expect(database.withTransactionAsync).toHaveBeenCalledTimes(1); + }); + + test("rejects a database newer than the package schema without migrating it", async () => { + const executed: string[] = []; + const database = fakeDatabase(MOBILE_SCHEMA_VERSION + 1, executed); + + await expect(migrateMobileDatabase(database)).rejects.toThrow( + `Unsupported mobile database version ${MOBILE_SCHEMA_VERSION + 1}`, + ); + expect(database.withTransactionAsync).not.toHaveBeenCalled(); + expect(executed.join("\n")).not.toContain( + "CREATE TABLE IF NOT EXISTS drills", + ); + }); +}); + +function fakeDatabase(version: number, executed: string[]) { + return { + execAsync: jest.fn(async (sql: string) => { + executed.push(sql); + }), + getFirstAsync: jest.fn(async () => ({ user_version: version })), + runAsync: jest.fn(async () => ({ lastInsertRowId: 1, changes: 1 })), + withTransactionAsync: jest.fn( + async (task: () => Promise) => await task(), + ), + } as unknown as SQLiteDatabase & { + runAsync: jest.Mock; + withTransactionAsync: jest.Mock; + }; +} diff --git a/packages/mobile/src/storage/index.ts b/packages/mobile/src/storage/index.ts new file mode 100644 index 00000000..a74b2c79 --- /dev/null +++ b/packages/mobile/src/storage/index.ts @@ -0,0 +1,2 @@ +export * from "./mobileDatabase"; +export * from "../mobile-repositories"; diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts new file mode 100644 index 00000000..4f7b4704 --- /dev/null +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -0,0 +1,138 @@ +import type { SQLiteDatabase } from "expo-sqlite"; + +/** The app database is deliberately separate from the PANS manager database. */ +export const MOBILE_DB_NAME = "eight2five-mobile.db"; +export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; + +/** + * The schema owner for the app database. Repositories never run migrations on + * their own; `openMobileRepositories` calls this function once before it + * constructs either repository. + */ +export const MOBILE_SCHEMA_VERSION = 1; + +export const MOBILE_SCHEMA_MIGRATIONS_TABLE = "mobile_schema_migrations"; +export const DRILLS_TABLE = "drills"; +export const DRILL_PAGES_TABLE = "drill_pages"; +export const APP_SETTINGS_TABLE = "app_settings"; + +export class MobileStorageError extends Error { + readonly cause?: unknown; + + constructor(message: string, options?: { cause?: unknown }) { + super(message); + this.name = "MobileStorageError"; + if (options?.cause !== undefined) this.cause = options.cause; + } +} + +/** + * Migrate the app-side database. + * + * This is intentionally the only migration owner for the mobile database. + * The PANS manager database has its own file and its own user_version and is + * not touched here. + */ +export async function migrateMobileDatabase(db: SQLiteDatabase): Promise { + await db.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); + + const row = await db.getFirstAsync<{ user_version: number | string }>( + "PRAGMA user_version", + ); + const currentVersion = parseSchemaVersion(row?.user_version); + if (currentVersion > MOBILE_SCHEMA_VERSION) { + throw new MobileStorageError( + `Unsupported mobile database version ${currentVersion}.`, + ); + } + + if (currentVersion === 0) { + await db.withTransactionAsync(async () => { + await db.execAsync(` + CREATE TABLE IF NOT EXISTS ${MOBILE_SCHEMA_MIGRATIONS_TABLE} ( + version INTEGER PRIMARY KEY NOT NULL, + applied_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ${DRILLS_TABLE} ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_drills_created_at + ON ${DRILLS_TABLE}(created_at, id); + + CREATE TABLE IF NOT EXISTS ${DRILL_PAGES_TABLE} ( + id TEXT PRIMARY KEY NOT NULL, + drill_id TEXT NOT NULL + REFERENCES ${DRILLS_TABLE}(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL + CHECK (ordinal >= 0 AND ordinal = CAST(ordinal AS INTEGER)), + label TEXT NOT NULL CHECK (length(trim(label)) > 0), + counts_from_previous INTEGER NOT NULL + CHECK (counts_from_previous >= 0), + x_meters REAL NOT NULL + CHECK (x_meters = x_meters), + y_meters REAL NOT NULL + CHECK (y_meters = y_meters), + UNIQUE (drill_id, ordinal) + ); + + CREATE INDEX IF NOT EXISTS idx_drill_pages_drill + ON ${DRILL_PAGES_TABLE}(drill_id, ordinal, id); + + CREATE TABLE IF NOT EXISTS ${APP_SETTINGS_TABLE} ( + singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), + drill_features_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (drill_features_enabled IN (0, 1)), + drill_terminology TEXT NOT NULL DEFAULT 'pages' + CHECK (drill_terminology IN ('pages', 'sets')), + field_perspective TEXT NOT NULL DEFAULT 'director' + CHECK (field_perspective IN ('director', 'performer')), + transition_metric_mode TEXT NOT NULL DEFAULT 'step-size' + CHECK (transition_metric_mode IN ('step-size', 'crossing-counts')), + guidance_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (guidance_enabled IN (0, 1)), + developer_mode_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (developer_mode_enabled IN (0, 1)), + show_cached_anchor_geometry INTEGER NOT NULL DEFAULT 0 + CHECK (show_cached_anchor_geometry IN (0, 1)), + show_comfortable_anchor_range INTEGER NOT NULL DEFAULT 0 + CHECK (show_comfortable_anchor_range IN (0, 1)), + comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 + CHECK (comfortable_anchor_range_meters > 0), + active_drill_id TEXT + REFERENCES ${DRILLS_TABLE}(id) ON DELETE SET NULL, + selected_drill_page_id TEXT + REFERENCES ${DRILL_PAGES_TABLE}(id) ON DELETE SET NULL + ); + + INSERT OR IGNORE INTO ${APP_SETTINGS_TABLE} (singleton_id) + VALUES (1); + `); + + await db.runAsync( + `INSERT OR REPLACE INTO ${MOBILE_SCHEMA_MIGRATIONS_TABLE} + (version, applied_at) VALUES (?, ?)`, + [MOBILE_SCHEMA_VERSION, Date.now()], + ); + await db.execAsync(`PRAGMA user_version = ${MOBILE_SCHEMA_VERSION};`); + }); + } + + // Keep this enabled for every connection, including an already migrated one. + await db.execAsync("PRAGMA foreign_keys = ON;"); +} + +function parseSchemaVersion(value: number | string | undefined): number { + if (value === undefined) return 0; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new MobileStorageError( + `Invalid mobile database version ${String(value)}.`, + ); + } + return parsed; +} From 46683984532c04e90e1060ce7ef15a42f74ee144 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 21:44:13 -0500 Subject: [PATCH 005/101] feat(mobile): implement core application settings --- apps/mobile/app/(tabs)/settings/advanced.tsx | 9 +- apps/mobile/app/(tabs)/settings/index.tsx | 9 +- .../__tests__/settings-actions.test.ts | 53 +++ .../settings/advanced-settings-screen.tsx | 80 +++++ .../settings/reset-settings-control.tsx | 55 ++++ .../src/features/settings/settings-actions.ts | 34 ++ .../features/settings/settings-components.tsx | 305 ++++++++++++++++++ .../src/features/settings/settings-screen.tsx | 150 +++++++++ .../navigation/tab-bar-visibility-context.tsx | 6 +- 9 files changed, 685 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/features/settings/__tests__/settings-actions.test.ts create mode 100644 apps/mobile/src/features/settings/advanced-settings-screen.tsx create mode 100644 apps/mobile/src/features/settings/reset-settings-control.tsx create mode 100644 apps/mobile/src/features/settings/settings-actions.ts create mode 100644 apps/mobile/src/features/settings/settings-components.tsx create mode 100644 apps/mobile/src/features/settings/settings-screen.tsx diff --git a/apps/mobile/app/(tabs)/settings/advanced.tsx b/apps/mobile/app/(tabs)/settings/advanced.tsx index e52c2f2e..9a8a0333 100644 --- a/apps/mobile/app/(tabs)/settings/advanced.tsx +++ b/apps/mobile/app/(tabs)/settings/advanced.tsx @@ -1,10 +1,5 @@ -import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; +import { AdvancedSettingsScreen } from "../../../src/features/settings/advanced-settings-screen"; export default function AdvancedSettingsRoute() { - return ( - - ); + return ; } diff --git a/apps/mobile/app/(tabs)/settings/index.tsx b/apps/mobile/app/(tabs)/settings/index.tsx index 81ed2e5a..9d279423 100644 --- a/apps/mobile/app/(tabs)/settings/index.tsx +++ b/apps/mobile/app/(tabs)/settings/index.tsx @@ -1,10 +1,5 @@ -import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; +import { SettingsScreen } from "../../../src/features/settings/settings-screen"; export default function SettingsRoute() { - return ( - - ); + return ; } diff --git a/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts b/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts new file mode 100644 index 00000000..6148075e --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts @@ -0,0 +1,53 @@ +import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; + +import { + RESET_SETTINGS_MESSAGE, + resetAppSettings, + updateDrillFeatures, +} from "../settings-actions"; + +describe("settings actions", () => { + test("persists Drill disablement before reconfiguring native tabs", async () => { + const events: string[] = []; + const writer = { + update: jest.fn(async () => { + events.push("persist"); + return { ...DEFAULT_APP_SETTINGS, drillFeaturesEnabled: false }; + }), + resetPreferences: jest.fn(), + }; + + await updateDrillFeatures( + writer, + (enabled) => events.push(`reconfigure:${enabled}`), + false, + ); + + expect(writer.update).toHaveBeenCalledWith({ drillFeaturesEnabled: false }); + expect(events).toEqual(["persist", "reconfigure:false"]); + }); + + test("reset preserves selection through the repository and restores Drill", async () => { + const reset = { + ...DEFAULT_APP_SETTINGS, + activeDrillId: "drill-1", + selectedDrillPageId: "page-2", + }; + const writer = { + update: jest.fn(), + resetPreferences: jest.fn(async () => reset), + }; + const reconfigure = jest.fn(); + + await expect(resetAppSettings(writer, reconfigure)).resolves.toEqual(reset); + expect(reconfigure).toHaveBeenCalledTimes(1); + expect(reconfigure).toHaveBeenCalledWith(true); + }); + + test("reset confirmation states destructive boundaries", () => { + expect(RESET_SETTINGS_MESSAGE).toBe( + "This restores display, drill-feature, terminology, and developer preferences to their defaults.\n\n" + + "It does not delete drills, cached anchor positions, or modify PANS hardware.", + ); + }); +}); diff --git a/apps/mobile/src/features/settings/advanced-settings-screen.tsx b/apps/mobile/src/features/settings/advanced-settings-screen.tsx new file mode 100644 index 00000000..e3a3e498 --- /dev/null +++ b/apps/mobile/src/features/settings/advanced-settings-screen.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { Navigation, Route } from "lucide-react-native"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsSelectRow, + SettingsSwitchRow, +} from "./settings-components"; + +const TRANSITION_CHOICES = [ + { label: "Step Size", value: "step-size" }, + { label: "Crossing Counts", value: "crossing-counts" }, +] as const; + +export function AdvancedSettingsScreen() { + const store = useAppSettingsStore(); + const { status, settings, error: loadError } = useAppSettingsSnapshot(); + const [operationError, setOperationError] = React.useState(); + const disabled = status !== "ready"; + + const update = async ( + partial: + | { guidanceEnabled: boolean } + | { transitionMetricMode: TransitionMetricMode }, + ) => { + setOperationError(undefined); + try { + await store.update(partial); + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } + }; + + return ( + + {status === "loading" ? ( + Loading app settings… + ) : null} + {loadError || operationError ? ( + + {(operationError ?? loadError)?.message} + + ) : null} + + + icon={Route} + title="Transition metric" + description="Show Step Size or yard-line crossing counts." + value={settings.transitionMetricMode} + choices={TRANSITION_CHOICES} + onChange={(transitionMetricMode) => + void update({ transitionMetricMode }) + } + disabled={disabled} + testID="transition-metric-setting" + /> + + + void update({ guidanceEnabled })} + disabled={disabled} + testID="guidance-enabled-setting" + /> + + + ); +} diff --git a/apps/mobile/src/features/settings/reset-settings-control.tsx b/apps/mobile/src/features/settings/reset-settings-control.tsx new file mode 100644 index 00000000..7254ba4c --- /dev/null +++ b/apps/mobile/src/features/settings/reset-settings-control.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Alert } from "react-native"; +import { RotateCcw } from "lucide-react-native"; +import { Pressable } from "@eight2five/ui/components/pressable"; + +import { useTabBarVisibility } from "../../navigation/tab-bar-visibility-context"; +import { useAppSettingsStore } from "../../state/app-settings-store"; +import { RESET_SETTINGS_MESSAGE, resetAppSettings } from "./settings-actions"; +import { SettingsRowContent } from "./settings-components"; + +export function ResetSettingsControl({ + disabled, + onError, +}: { + disabled?: boolean; + onError(error: Error): void; +}) { + const store = useAppSettingsStore(); + const { reconfigureDrillFeatures } = useTabBarVisibility(); + const [resetting, setResetting] = React.useState(false); + + const reset = async () => { + setResetting(true); + try { + await resetAppSettings(store, reconfigureDrillFeatures); + } catch (cause) { + onError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setResetting(false); + } + }; + + const confirmReset = () => { + Alert.alert("Reset App Settings?", RESET_SETTINGS_MESSAGE, [ + { text: "Cancel", style: "cancel" }, + { + text: "Reset", + style: "destructive", + onPress: () => void reset(), + }, + ]); + }; + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/settings/settings-actions.ts b/apps/mobile/src/features/settings/settings-actions.ts new file mode 100644 index 00000000..2e3e4654 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-actions.ts @@ -0,0 +1,34 @@ +import type { + AppSettings, + AppSettingsUpdate, +} from "@eight2five/mobile/settings"; + +export interface SettingsWriter { + update(partial: AppSettingsUpdate): Promise; + resetPreferences(): Promise; +} + +export const RESET_SETTINGS_MESSAGE = + "This restores display, drill-feature, terminology, and developer preferences to their defaults.\n\n" + + "It does not delete drills, cached anchor positions, or modify PANS hardware."; + +/** Persistence completes before the native-tab layout is reconfigured. */ +export async function updateDrillFeatures( + writer: SettingsWriter, + reconfigureTabs: (enabled: boolean) => void, + enabled: boolean, +): Promise { + const settings = await writer.update({ drillFeaturesEnabled: enabled }); + reconfigureTabs(settings.drillFeaturesEnabled); + return settings; +} + +/** Reset affects preferences only, then reconciles Drill tab membership once. */ +export async function resetAppSettings( + writer: SettingsWriter, + reconfigureTabs: (enabled: boolean) => void, +): Promise { + const settings = await writer.resetPreferences(); + reconfigureTabs(settings.drillFeaturesEnabled); + return settings; +} diff --git a/apps/mobile/src/features/settings/settings-components.tsx b/apps/mobile/src/features/settings/settings-components.tsx new file mode 100644 index 00000000..75daaca6 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-components.tsx @@ -0,0 +1,305 @@ +import React from "react"; +import { + ChevronDown, + ChevronRight, + type LucideIcon, +} from "lucide-react-native"; +import { Card } from "@eight2five/ui/components/card"; +import { Heading } from "@eight2five/ui/components/heading"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { + Select, + SelectBackdrop, + SelectContent, + SelectDragIndicator, + SelectDragIndicatorWrapper, + SelectIcon, + SelectInput, + SelectItem, + SelectPortal, + SelectTrigger, +} from "@eight2five/ui/components/select"; +import { Switch } from "@eight2five/ui/components/switch"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +export function SettingsScreenContainer({ + children, +}: { + children: React.ReactNode; +}) { + const theme = useEight2FiveTheme(); + return ( + + {children} + + ); +} + +export function SettingsSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + const theme = useEight2FiveTheme(); + return ( + + + {title} + + + {children} + + + ); +} + +interface SettingsRowContentProps { + icon: LucideIcon; + title: string; + description?: string; + accessory?: React.ReactNode; + danger?: boolean; +} + +function SettingsRowContent({ + icon, + title, + description, + accessory, + danger = false, +}: SettingsRowContentProps) { + const theme = useEight2FiveTheme(); + const color = danger ? theme.danger : theme.text; + return ( + + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {accessory} + + ); +} + +export function SettingsNavigationRow({ + icon, + title, + description, + onPress, + testID, +}: SettingsRowContentProps & { onPress(): void; testID?: string }) { + const theme = useEight2FiveTheme(); + return ( + + + } + /> + + ); +} + +export function SettingsValueRow({ + icon, + title, + description, + value, +}: SettingsRowContentProps & { value: string }) { + const theme = useEight2FiveTheme(); + return ( + + {value} + + } + /> + ); +} + +export function SettingsSwitchRow({ + icon, + title, + description, + value, + onChange, + disabled, + testID, +}: SettingsRowContentProps & { + value: boolean; + onChange(value: boolean): void; + disabled?: boolean; + testID?: string; +}) { + const theme = useEight2FiveTheme(); + return ( + + } + /> + ); +} + +export interface SettingsSelectChoice { + readonly label: string; + readonly value: T; +} + +export function SettingsSelectRow({ + icon, + title, + description, + value, + choices, + onChange, + disabled, + testID, +}: SettingsRowContentProps & { + value: T; + choices: readonly SettingsSelectChoice[]; + onChange(value: T): void; + disabled?: boolean; + testID?: string; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + + ); +} + +export function SettingsMessage({ + tone, + children, +}: { + tone: "info" | "error"; + children: React.ReactNode; +}) { + const theme = useEight2FiveTheme(); + return ( + + + {children} + + + ); +} + +export { SettingsRowContent }; diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx new file mode 100644 index 00000000..982ed47d --- /dev/null +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { + Code2, + Eye, + ListChecks, + Radio, + SlidersHorizontal, + Tags, +} from "lucide-react-native"; +import type { DrillTerminology } from "@eight2five/mobile/drill"; +import type { + AppSettingsUpdate, + FieldPerspective, +} from "@eight2five/mobile/settings"; + +import { useTabBarVisibility } from "../../navigation/tab-bar-visibility-context"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { ResetSettingsControl } from "./reset-settings-control"; +import { updateDrillFeatures } from "./settings-actions"; +import { + SettingsMessage, + SettingsNavigationRow, + SettingsScreenContainer, + SettingsSection, + SettingsSelectRow, + SettingsSwitchRow, + SettingsValueRow, +} from "./settings-components"; + +const TERMINOLOGY_CHOICES = [ + { label: "Pages", value: "pages" }, + { label: "Sets", value: "sets" }, +] as const; + +const PERSPECTIVE_CHOICES = [ + { label: "Director", value: "director" }, + { label: "Performer", value: "performer" }, +] as const; + +export function SettingsScreen() { + const router = useRouter(); + const store = useAppSettingsStore(); + const { status, settings, error: loadError } = useAppSettingsSnapshot(); + const { reconfigureDrillFeatures } = useTabBarVisibility(); + const [operationError, setOperationError] = React.useState(); + const disabled = status !== "ready"; + + const update = async (partial: AppSettingsUpdate) => { + setOperationError(undefined); + try { + await store.update(partial); + } catch (cause) { + setOperationError(toError(cause)); + } + }; + + const setDrillFeatures = async (enabled: boolean) => { + setOperationError(undefined); + try { + await updateDrillFeatures(store, reconfigureDrillFeatures, enabled); + } catch (cause) { + setOperationError(toError(cause)); + } + }; + + return ( + + {status === "loading" ? ( + Loading app settings… + ) : null} + {loadError || operationError ? ( + + {(operationError ?? loadError)?.message} + + ) : null} + + + + + + + void setDrillFeatures(enabled)} + disabled={disabled} + testID="drill-features-setting" + /> + + icon={Tags} + title="Drill terminology" + description="Choose whether the app says Pages or Sets." + value={settings.drillTerminology} + choices={TERMINOLOGY_CHOICES} + onChange={(drillTerminology) => void update({ drillTerminology })} + disabled={disabled} + testID="drill-terminology-setting" + /> + + + + + icon={Eye} + title="Field perspective" + description="Choose the default semantic field view." + value={settings.fieldPerspective} + choices={PERSPECTIVE_CHOICES} + onChange={(fieldPerspective) => void update({ fieldPerspective })} + disabled={disabled} + testID="field-perspective-setting" + /> + + + + router.push("/(tabs)/settings/advanced")} + testID="advanced-settings-link" + /> + router.push("/(tabs)/settings/developer")} + testID="developer-settings-link" + /> + + + + + + + ); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx index db031bba..75df8af8 100644 --- a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx +++ b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx @@ -39,6 +39,7 @@ export function TabBarVisibilityProvider({ ...INITIAL_MOBILE_TAB_NAVIGATION_STATE, drillFeaturesEnabled, }); + const configuredDrillFeatures = React.useRef(drillFeaturesEnabled); const setFieldPresentation = React.useCallback( ({ focused, landscape }: FieldPresentation) => { @@ -53,12 +54,13 @@ export function TabBarVisibilityProvider({ const reconfigureDrillFeatures = React.useCallback( (enabled: boolean) => { - if (state.drillFeaturesEnabled === enabled) return; + if (configuredDrillFeatures.current === enabled) return; + configuredDrillFeatures.current = enabled; router.replace("/(tabs)/field"); dispatch({ type: "drill-features-reconfigured", enabled }); }, - [router, state.drillFeaturesEnabled], + [router], ); React.useEffect(() => { From 76b00359e47d58e7e7ad70c505e75d25e8bc0161 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 21:54:18 -0500 Subject: [PATCH 006/101] chore(mobile): stabilize MVP foundation --- .../__tests__/app-settings-store.test.ts | 31 +++++++++++++++++++ apps/mobile/src/state/app-settings-store.tsx | 25 +++++++++++++-- packages/mobile/src/settings/types.ts | 3 +- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/state/__tests__/app-settings-store.test.ts b/apps/mobile/src/state/__tests__/app-settings-store.test.ts index f2a22b96..85cb1765 100644 --- a/apps/mobile/src/state/__tests__/app-settings-store.test.ts +++ b/apps/mobile/src/state/__tests__/app-settings-store.test.ts @@ -95,6 +95,37 @@ describe("AppSettingsStore", () => { ); }); + test("waits for queued writes before closing storage", async () => { + let releaseUpdate!: () => void; + const close = jest.fn(async () => undefined); + const storage = { + settingsRepository: { + load: jest.fn(async () => DEFAULT_APP_SETTINGS), + update: jest.fn( + async () => + await new Promise((resolve) => { + releaseUpdate = () => resolve(DEFAULT_APP_SETTINGS); + }), + ), + resetPreferences: jest.fn(async () => DEFAULT_APP_SETTINGS), + }, + drillRepository: {}, + close, + } as unknown as OpenMobileRepositoriesResult; + const store = new AppSettingsStore(async () => storage); + await store.initialize(); + + const update = store.update({ guidanceEnabled: false }); + await Promise.resolve(); + const disposal = store.dispose(); + expect(close).not.toHaveBeenCalled(); + + releaseUpdate(); + await update; + await disposal; + expect(close).toHaveBeenCalledTimes(1); + }); + test("derives the persisted field session contract", () => { expect( selectFieldSession({ diff --git a/apps/mobile/src/state/app-settings-store.tsx b/apps/mobile/src/state/app-settings-store.tsx index d78d436b..67550420 100644 --- a/apps/mobile/src/state/app-settings-store.tsx +++ b/apps/mobile/src/state/app-settings-store.tsx @@ -48,22 +48,30 @@ export class AppSettingsStore { async initialize(): Promise { const generation = ++this.lifecycleGeneration; this.publish(INITIAL_SNAPSHOT); + const previousStorage = this.storage; + this.storage = undefined; + if (previousStorage) { + await this.writeQueue; + await closeStorageQuietly(previousStorage); + if (generation !== this.lifecycleGeneration) return; + } let storage: OpenMobileRepositoriesResult | undefined; try { storage = await this.openStorage(); if (generation !== this.lifecycleGeneration) { - await storage.close(); + await closeStorageQuietly(storage); return; } const settings = await storage.settingsRepository.load(); if (generation !== this.lifecycleGeneration) { - await storage.close(); + await closeStorageQuietly(storage); return; } this.storage = storage; this.publish(Object.freeze({ status: "ready", settings })); } catch (cause) { - if (storage && storage !== this.storage) await storage.close(); + if (storage && storage !== this.storage) + await closeStorageQuietly(storage); if (generation !== this.lifecycleGeneration) return; this.publish( Object.freeze({ @@ -123,6 +131,7 @@ export class AppSettingsStore { this.lifecycleGeneration += 1; const storage = this.storage; this.storage = undefined; + await this.writeQueue; if (storage) await storage.close(); } @@ -200,3 +209,13 @@ export function useAppSettingsSnapshot(): AppSettingsStoreSnapshot { function toError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)); } + +async function closeStorageQuietly( + storage: OpenMobileRepositoriesResult, +): Promise { + try { + await storage.close(); + } catch { + // Preserve initialization errors; disposal reports its own close failure. + } +} diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts index 2e855bc8..c9de4af1 100644 --- a/packages/mobile/src/settings/types.ts +++ b/packages/mobile/src/settings/types.ts @@ -24,8 +24,6 @@ export interface AppSettings { readonly selectedDrillPageId: string | null; } -export type AppSettingsUpdate = Partial; - export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ drillFeaturesEnabled: true, drillTerminology: "pages", @@ -54,6 +52,7 @@ export const APP_PREFERENCE_KEYS = Object.freeze([ ] as const satisfies readonly (keyof AppSettings)[]); export type AppPreferenceKey = (typeof APP_PREFERENCE_KEYS)[number]; +export type AppSettingsUpdate = Partial>; export interface AppSettingsRepository { load(): Promise; From 12808a4af3767f61cd2b4d96617687960a932d6f Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 22:06:27 -0500 Subject: [PATCH 007/101] feat(drill): scaffold manual drill workflows --- .../app/(tabs)/drill/[drillId]/index.tsx | 8 ++++++++ .../(tabs)/drill/[drillId]/page/[pageId].tsx | 11 ++++++++++ apps/mobile/app/(tabs)/drill/_layout.tsx | 17 +++++++++++++++- apps/mobile/app/(tabs)/drill/index.tsx | 4 ++-- apps/mobile/app/(tabs)/drill/new.tsx | 5 +++++ .../features/drill/drill-editor-screen.tsx | 16 +++++++++++++++ .../src/features/drill/drill-list-screen.tsx | 12 +++++++++++ .../src/features/drill/drill-screen.tsx | 10 ---------- .../src/features/drill/page-editor-screen.tsx | 20 +++++++++++++++++++ .../drill/use-drill-editor-controller.ts | 15 ++++++++++++++ .../drill/use-drill-list-controller.ts | 14 +++++++++++++ .../drill/use-page-editor-controller.ts | 15 ++++++++++++++ 12 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/app/(tabs)/drill/[drillId]/index.tsx create mode 100644 apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx create mode 100644 apps/mobile/app/(tabs)/drill/new.tsx create mode 100644 apps/mobile/src/features/drill/drill-editor-screen.tsx create mode 100644 apps/mobile/src/features/drill/drill-list-screen.tsx delete mode 100644 apps/mobile/src/features/drill/drill-screen.tsx create mode 100644 apps/mobile/src/features/drill/page-editor-screen.tsx create mode 100644 apps/mobile/src/features/drill/use-drill-editor-controller.ts create mode 100644 apps/mobile/src/features/drill/use-drill-list-controller.ts create mode 100644 apps/mobile/src/features/drill/use-page-editor-controller.ts diff --git a/apps/mobile/app/(tabs)/drill/[drillId]/index.tsx b/apps/mobile/app/(tabs)/drill/[drillId]/index.tsx new file mode 100644 index 00000000..9172484e --- /dev/null +++ b/apps/mobile/app/(tabs)/drill/[drillId]/index.tsx @@ -0,0 +1,8 @@ +import { useLocalSearchParams } from "expo-router"; + +import { DrillEditorScreen } from "../../../../src/features/drill/drill-editor-screen"; + +export default function ExistingDrillRoute() { + const { drillId } = useLocalSearchParams<{ drillId: string }>(); + return ; +} diff --git a/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx b/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx new file mode 100644 index 00000000..aa2ec0fb --- /dev/null +++ b/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx @@ -0,0 +1,11 @@ +import { useLocalSearchParams } from "expo-router"; + +import { PageEditorScreen } from "../../../../../src/features/drill/page-editor-screen"; + +export default function DrillPageRoute() { + const { drillId, pageId } = useLocalSearchParams<{ + drillId: string; + pageId: string; + }>(); + return ; +} diff --git a/apps/mobile/app/(tabs)/drill/_layout.tsx b/apps/mobile/app/(tabs)/drill/_layout.tsx index 5bdd590c..3ef37205 100644 --- a/apps/mobile/app/(tabs)/drill/_layout.tsx +++ b/apps/mobile/app/(tabs)/drill/_layout.tsx @@ -1,8 +1,17 @@ -import { Stack } from "expo-router"; +import { Redirect, Stack } from "expo-router"; import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; +import { useAppSettingsSnapshot } from "../../../src/state/app-settings-store"; + export default function DrillLayout() { const theme = useEight2FiveTheme(); + const { status, settings } = useAppSettingsSnapshot(); + + // Keep disabled drill routes inaccessible even when opened from a stale link. + if (status === "loading") return null; + if (status === "error" || !settings.drillFeaturesEnabled) { + return ; + } return ( + + + ); } diff --git a/apps/mobile/app/(tabs)/drill/index.tsx b/apps/mobile/app/(tabs)/drill/index.tsx index eab81152..6fbbb336 100644 --- a/apps/mobile/app/(tabs)/drill/index.tsx +++ b/apps/mobile/app/(tabs)/drill/index.tsx @@ -1,5 +1,5 @@ -import { DrillScreen } from "../../../src/features/drill/drill-screen"; +import { DrillListScreen } from "../../../src/features/drill/drill-list-screen"; export default function DrillRoute() { - return ; + return ; } diff --git a/apps/mobile/app/(tabs)/drill/new.tsx b/apps/mobile/app/(tabs)/drill/new.tsx new file mode 100644 index 00000000..113ef5e8 --- /dev/null +++ b/apps/mobile/app/(tabs)/drill/new.tsx @@ -0,0 +1,5 @@ +import { DrillEditorScreen } from "../../../src/features/drill/drill-editor-screen"; + +export default function NewDrillRoute() { + return ; +} diff --git a/apps/mobile/src/features/drill/drill-editor-screen.tsx b/apps/mobile/src/features/drill/drill-editor-screen.tsx new file mode 100644 index 00000000..7b45a104 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-editor-screen.tsx @@ -0,0 +1,16 @@ +import { PlaceholderScreen } from "../placeholder-screen"; +import { useDrillEditorController } from "./use-drill-editor-controller"; + +export function DrillEditorScreen({ drillId }: { drillId?: string }) { + const { terms } = useDrillEditorController(drillId); + return ( + + ); +} diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx new file mode 100644 index 00000000..568a6212 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-list-screen.tsx @@ -0,0 +1,12 @@ +import { PlaceholderScreen } from "../placeholder-screen"; +import { useDrillListController } from "./use-drill-list-controller"; + +export function DrillListScreen() { + const { terms } = useDrillListController(); + return ( + + ); +} diff --git a/apps/mobile/src/features/drill/drill-screen.tsx b/apps/mobile/src/features/drill/drill-screen.tsx deleted file mode 100644 index d6558c6d..00000000 --- a/apps/mobile/src/features/drill/drill-screen.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { PlaceholderScreen } from "../placeholder-screen"; - -export function DrillScreen() { - return ( - - ); -} diff --git a/apps/mobile/src/features/drill/page-editor-screen.tsx b/apps/mobile/src/features/drill/page-editor-screen.tsx new file mode 100644 index 00000000..cc1e7dfe --- /dev/null +++ b/apps/mobile/src/features/drill/page-editor-screen.tsx @@ -0,0 +1,20 @@ +import { PlaceholderScreen } from "../placeholder-screen"; +import { usePageEditorController } from "./use-page-editor-controller"; + +export function PageEditorScreen({ + drillId, + pageId, +}: { + drillId: string; + pageId: string; +}) { + const { terms } = usePageEditorController(drillId, pageId); + return ( + + ); +} diff --git a/apps/mobile/src/features/drill/use-drill-editor-controller.ts b/apps/mobile/src/features/drill/use-drill-editor-controller.ts new file mode 100644 index 00000000..53929292 --- /dev/null +++ b/apps/mobile/src/features/drill/use-drill-editor-controller.ts @@ -0,0 +1,15 @@ +import { getDrillTerms } from "@eight2five/mobile/drill"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; + +/** Shared controller boundary for create and existing-drill routes. */ +export function useDrillEditorController(drillId?: string) { + const snapshot = useAppSettingsSnapshot(); + return { + drillId, + status: snapshot.status, + settings: snapshot.settings, + terms: getDrillTerms(snapshot.settings.drillTerminology), + error: snapshot.error, + } as const; +} diff --git a/apps/mobile/src/features/drill/use-drill-list-controller.ts b/apps/mobile/src/features/drill/use-drill-list-controller.ts new file mode 100644 index 00000000..1eeb9800 --- /dev/null +++ b/apps/mobile/src/features/drill/use-drill-list-controller.ts @@ -0,0 +1,14 @@ +import { getDrillTerms } from "@eight2five/mobile/drill"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; + +/** Initial route-level contract; persistence actions are added in Phase 2. */ +export function useDrillListController() { + const snapshot = useAppSettingsSnapshot(); + return { + status: snapshot.status, + settings: snapshot.settings, + terms: getDrillTerms(snapshot.settings.drillTerminology), + error: snapshot.error, + } as const; +} diff --git a/apps/mobile/src/features/drill/use-page-editor-controller.ts b/apps/mobile/src/features/drill/use-page-editor-controller.ts new file mode 100644 index 00000000..adb4c9b8 --- /dev/null +++ b/apps/mobile/src/features/drill/use-page-editor-controller.ts @@ -0,0 +1,15 @@ +import { getDrillTerms } from "@eight2five/mobile/drill"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; + +/** Controller boundary for append, insert, and existing-page edit routes. */ +export function usePageEditorController(drillId: string, pageId: string) { + const snapshot = useAppSettingsSnapshot(); + return { + drillId, + pageId, + status: snapshot.status, + terms: getDrillTerms(snapshot.settings.drillTerminology), + error: snapshot.error, + } as const; +} From e6264e60c57babc618057fb97e712fe786eb9423 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 22:14:01 -0500 Subject: [PATCH 008/101] feat(drill): implement drill management --- .../drill/__tests__/drill-management.test.ts | 86 +++++++++ .../components/destructive-drill-actions.tsx | 73 +++++++ .../drill/components/drill-empty-state.tsx | 51 +++++ .../drill/components/drill-list-item.tsx | 93 +++++++++ .../drill/components/drill-name-dialog.tsx | 44 +++++ .../drill/components/drill-name-form.tsx | 95 ++++++++++ .../features/drill/drill-editor-screen.tsx | 178 +++++++++++++++++- .../src/features/drill/drill-list-screen.tsx | 154 ++++++++++++++- .../src/features/drill/drill-management.ts | 68 +++++++ .../drill/use-drill-editor-controller.ts | 133 ++++++++++++- .../drill/use-drill-list-controller.ts | 125 +++++++++++- 11 files changed, 1072 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/features/drill/__tests__/drill-management.test.ts create mode 100644 apps/mobile/src/features/drill/components/destructive-drill-actions.tsx create mode 100644 apps/mobile/src/features/drill/components/drill-empty-state.tsx create mode 100644 apps/mobile/src/features/drill/components/drill-list-item.tsx create mode 100644 apps/mobile/src/features/drill/components/drill-name-dialog.tsx create mode 100644 apps/mobile/src/features/drill/components/drill-name-form.tsx create mode 100644 apps/mobile/src/features/drill/drill-management.ts diff --git a/apps/mobile/src/features/drill/__tests__/drill-management.test.ts b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts new file mode 100644 index 00000000..2f048741 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts @@ -0,0 +1,86 @@ +import type { DrillRepository } from "@eight2five/mobile/drill"; + +import { + DRILL_NAME_MAX_LENGTH, + createNamedDrill, + deleteDrillAndRefreshSettings, + loadDrillList, + renameNamedDrill, + validateDrillName, +} from "../drill-management"; + +describe("manual drill management", () => { + test("loads deterministic drill rows with page counts and supports empty state", async () => { + const repository = { + listDrills: jest.fn(async () => [ + { id: "a", name: "First", createdAt: 1, updatedAt: 1 }, + { id: "b", name: "Second", createdAt: 2, updatedAt: 2 }, + ]), + listPages: jest.fn(async (drillId: string) => + drillId === "a" + ? [ + { + id: "page-a", + drillId, + ordinal: 0, + label: "1", + countsFromPrevious: 0, + position: { xMeters: 0, yMeters: 0 }, + }, + ] + : [], + ), + } as unknown as DrillRepository; + + await expect(loadDrillList(repository)).resolves.toEqual([ + { drill: expect.objectContaining({ id: "a" }), pageCount: 1 }, + { drill: expect.objectContaining({ id: "b" }), pageCount: 0 }, + ]); + expect(repository.listPages).toHaveBeenCalledWith("a"); + expect(repository.listPages).toHaveBeenCalledWith("b"); + + repository.listDrills = jest.fn(async () => []); + await expect(loadDrillList(repository)).resolves.toEqual([]); + }); + + test("trims and validates names for create and rename", async () => { + const created = { id: "new", name: "Show", createdAt: 1, updatedAt: 1 }; + const repository = { + createDrill: jest.fn(async () => created), + renameDrill: jest.fn(async () => ({ ...created, name: "Finale" })), + } as unknown as DrillRepository; + + await expect(createNamedDrill(repository, " Show ")).resolves.toBe( + created, + ); + expect(repository.createDrill).toHaveBeenCalledWith("Show"); + await renameNamedDrill(repository, "new", " Finale "); + expect(repository.renameDrill).toHaveBeenCalledWith("new", "Finale"); + + expect(validateDrillName(" ")).toBe("Enter a drill name."); + expect(validateDrillName("x".repeat(DRILL_NAME_MAX_LENGTH + 1))).toContain( + String(DRILL_NAME_MAX_LENGTH), + ); + await expect(createNamedDrill(repository, " ")).rejects.toThrow( + "Enter a drill name", + ); + }); + + test("deletes the drill before refreshing cleared selection pointers", async () => { + const order: string[] = []; + const repository = { + deleteDrill: jest.fn(async () => { + order.push("delete"); + }), + } as unknown as DrillRepository; + const reload = jest.fn(async () => { + order.push("reload"); + }); + + await deleteDrillAndRefreshSettings(repository, "active", reload); + + expect(repository.deleteDrill).toHaveBeenCalledWith("active"); + expect(reload).toHaveBeenCalledTimes(1); + expect(order).toEqual(["delete", "reload"]); + }); +}); diff --git a/apps/mobile/src/features/drill/components/destructive-drill-actions.tsx b/apps/mobile/src/features/drill/components/destructive-drill-actions.tsx new file mode 100644 index 00000000..be5443ca --- /dev/null +++ b/apps/mobile/src/features/drill/components/destructive-drill-actions.tsx @@ -0,0 +1,73 @@ +import { Alert } from "react-native"; +import { Check, Pencil, Trash2 } from "lucide-react-native"; +import type { Drill, DrillTerms } from "@eight2five/mobile/drill"; +import { + Actionsheet, + ActionsheetBackdrop, + ActionsheetContent, + ActionsheetDragIndicator, + ActionsheetDragIndicatorWrapper, + ActionsheetIcon, + ActionsheetItem, + ActionsheetItemText, +} from "@eight2five/ui/components/actionsheet"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; + +export function confirmDeleteDrill( + drill: Drill, + terms: DrillTerms, + onConfirm: () => void, +) { + Alert.alert( + `Delete “${drill.name}”?`, + `This permanently deletes the drill and all of its ${terms.lowercasePlural}.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Delete", style: "destructive", onPress: onConfirm }, + ], + ); +} + +export function DrillActionsSheet({ + drill, + active, + onClose, + onMakeActive, + onRename, + onDelete, +}: { + drill?: Drill; + active: boolean; + onClose(): void; + onMakeActive(): void; + onRename(): void; + onDelete(): void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + + + + {!active ? ( + + + Make active + + ) : null} + + + Rename + + + + + Delete + + + + + ); +} diff --git a/apps/mobile/src/features/drill/components/drill-empty-state.tsx b/apps/mobile/src/features/drill/components/drill-empty-state.tsx new file mode 100644 index 00000000..3c389827 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-empty-state.tsx @@ -0,0 +1,51 @@ +import { NotebookTabs, Plus } from "lucide-react-native"; +import type { DrillTerms } from "@eight2five/mobile/drill"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Center } from "@eight2five/ui/components/center"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +export function DrillEmptyState({ + terms, + onCreate, +}: { + terms: DrillTerms; + onCreate(): void; +}) { + const theme = useEight2FiveTheme(); + return ( +
+ + + + No drills yet + + + Drills are entered manually. Create one to start adding{" "} + {terms.lowercasePlural}. + + + +
+ ); +} diff --git a/apps/mobile/src/features/drill/components/drill-list-item.tsx b/apps/mobile/src/features/drill/components/drill-list-item.tsx new file mode 100644 index 00000000..89f5de6f --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-list-item.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { EllipsisVertical } from "lucide-react-native"; +import type { Drill, DrillTerms } from "@eight2five/mobile/drill"; +import { Card } from "@eight2five/ui/components/card"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +export const DrillListItem = React.memo(function DrillListItem({ + drill, + pageCount, + terms, + active, + busy, + onOpen, + onOpenActions, +}: { + drill: Drill; + pageCount: number; + terms: DrillTerms; + active: boolean; + busy: boolean; + onOpen(): void; + onOpenActions(): void; +}) { + const theme = useEight2FiveTheme(); + const countLabel = `${pageCount} ${ + pageCount === 1 ? terms.singular : terms.plural + }`; + return ( + + + + + + {drill.name} + + + {countLabel} + + {active ? ( + + Active + + ) : null} + + + + + + + + ); +}); diff --git a/apps/mobile/src/features/drill/components/drill-name-dialog.tsx b/apps/mobile/src/features/drill/components/drill-name-dialog.tsx new file mode 100644 index 00000000..3ccdcbd1 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-name-dialog.tsx @@ -0,0 +1,44 @@ +import { + Modal, + ModalBackdrop, + ModalBody, + ModalContent, + ModalHeader, +} from "@eight2five/ui/components/modal"; +import { Heading } from "@eight2five/ui/components/heading"; + +import { DrillNameForm } from "./drill-name-form"; + +export function DrillNameDialog({ + isOpen, + initialValue, + saving, + onClose, + onSave, +}: { + isOpen: boolean; + initialValue: string; + saving: boolean; + onClose(): void; + onSave(name: string): Promise; +}) { + return ( + + + + + Rename Drill + + + + + + + ); +} diff --git a/apps/mobile/src/features/drill/components/drill-name-form.tsx b/apps/mobile/src/features/drill/components/drill-name-form.tsx new file mode 100644 index 00000000..d25cc4a5 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-name-form.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { + Button, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { + FormControl, + FormControlError, + FormControlErrorText, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing } from "@eight2five/ui/theme"; + +import { DRILL_NAME_MAX_LENGTH, validateDrillName } from "../drill-management"; + +export function DrillNameForm({ + initialValue = "", + submitLabel, + saving, + onSubmit, +}: { + initialValue?: string; + submitLabel: string; + saving: boolean; + onSubmit(name: string): Promise; +}) { + const [name, setName] = React.useState(initialValue); + const [error, setError] = React.useState(); + const submittingRef = React.useRef(false); + + const submit = async () => { + if (saving || submittingRef.current) return; + const validationError = validateDrillName(name); + setError(validationError); + if (validationError) return; + submittingRef.current = true; + try { + await onSubmit(name); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + submittingRef.current = false; + } + }; + + return ( + + + + Drill name + + + { + setName(value); + if (error) setError(undefined); + }} + autoCapitalize="words" + autoCorrect + maxLength={DRILL_NAME_MAX_LENGTH} + returnKeyType="done" + submitBehavior="blurAndSubmit" + onSubmitEditing={() => void submit()} + accessibilityLabel="Drill name" + /> + + + + Up to {DRILL_NAME_MAX_LENGTH} characters. + + + + {error} + + + + + ); +} diff --git a/apps/mobile/src/features/drill/drill-editor-screen.tsx b/apps/mobile/src/features/drill/drill-editor-screen.tsx index 7b45a104..2be6bd6e 100644 --- a/apps/mobile/src/features/drill/drill-editor-screen.tsx +++ b/apps/mobile/src/features/drill/drill-editor-screen.tsx @@ -1,16 +1,174 @@ -import { PlaceholderScreen } from "../placeholder-screen"; +import React from "react"; +import { useRouter } from "expo-router"; +import { Check, Pencil, Trash2 } from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Card } from "@eight2five/ui/components/card"; +import { Heading } from "@eight2five/ui/components/heading"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { SettingsMessage } from "../settings/settings-components"; +import { confirmDeleteDrill } from "./components/destructive-drill-actions"; +import { DrillNameDialog } from "./components/drill-name-dialog"; +import { DrillNameForm } from "./components/drill-name-form"; import { useDrillEditorController } from "./use-drill-editor-controller"; export function DrillEditorScreen({ drillId }: { drillId?: string }) { - const { terms } = useDrillEditorController(drillId); + const router = useRouter(); + const theme = useEight2FiveTheme(); + const controller = useDrillEditorController(drillId); + const [renaming, setRenaming] = React.useState(false); + + if (!drillId) { + return ( + + Create Drill + + Name the drill before entering {controller.terms.lowercasePlural}. + + { + const created = await controller.saveName(name); + router.replace(`/(tabs)/drill/${created.id}`); + }} + /> + + ); + } + + const drill = controller.drill; + const deleteDrill = () => { + if (!drill) return; + confirmDeleteDrill(drill, controller.terms, () => { + void controller + .remove() + .then(() => router.replace("/(tabs)/drill")) + .catch(() => undefined); + }); + }; + return ( - + + {controller.loading ? ( + Loading drill… + ) : null} + {controller.error ? ( + + {controller.error.message} + + ) : null} + {drill ? ( + <> + + + {drill.name} + + + {controller.pages.length}{" "} + {controller.pages.length === 1 + ? controller.terms.singular + : controller.terms.plural} + + + {controller.active ? "Active" : "Inactive"} + + + + + {!controller.active ? ( + + ) : null} + + + + + setRenaming(false)} + onSave={async (name) => { + await controller.saveName(name); + setRenaming(false); + }} + /> + + ) : null} + ); } diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx index 568a6212..dbde652f 100644 --- a/apps/mobile/src/features/drill/drill-list-screen.tsx +++ b/apps/mobile/src/features/drill/drill-list-screen.tsx @@ -1,12 +1,154 @@ -import { PlaceholderScreen } from "../placeholder-screen"; +import React from "react"; +import { useRouter } from "expo-router"; +import { Plus } from "lucide-react-native"; +import type { Drill } from "@eight2five/mobile/drill"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { FlatList } from "@eight2five/ui/components/flat-list"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { SettingsMessage } from "../settings/settings-components"; +import { + DrillActionsSheet, + confirmDeleteDrill, +} from "./components/destructive-drill-actions"; +import { DrillEmptyState } from "./components/drill-empty-state"; +import { DrillListItem } from "./components/drill-list-item"; +import { DrillNameDialog } from "./components/drill-name-dialog"; import { useDrillListController } from "./use-drill-list-controller"; export function DrillListScreen() { - const { terms } = useDrillListController(); + const router = useRouter(); + const theme = useEight2FiveTheme(); + const controller = useDrillListController(); + const [actionDrill, setActionDrill] = React.useState(); + const [renameDrill, setRenameDrill] = React.useState(); + + const openDrill = React.useCallback( + (drill: Drill) => router.push(`/(tabs)/drill/${drill.id}`), + [router], + ); + + const openActions = React.useCallback((drill: Drill) => { + setActionDrill(drill); + }, []); + + const renderItem = React.useCallback( + ({ item }: { item: (typeof controller.entries)[number] }) => ( + openDrill(item.drill)} + onOpenActions={() => openActions(item.drill)} + /> + ), + [controller, openActions, openDrill], + ); + + const beginRename = () => { + setRenameDrill(actionDrill); + setActionDrill(undefined); + }; + + const beginDelete = () => { + const drill = actionDrill; + setActionDrill(undefined); + if (!drill) return; + confirmDeleteDrill(drill, controller.terms, () => { + void controller.remove(drill).catch(() => undefined); + }); + }; + return ( - + + entry.drill.id} + renderItem={renderItem} + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ + flexGrow: 1, + gap: eight2FiveSpacing.sm, + padding: eight2FiveSpacing.md, + paddingBottom: eight2FiveSpacing.xxl, + }} + ListHeaderComponent={ + + + Drills + + {controller.entries.length > 0 ? ( + + ) : null} + {controller.loading ? ( + Loading drills… + ) : null} + {controller.error ? ( + + {controller.error.message} + + ) : null} + + } + ListEmptyComponent={ + controller.loading ? null : ( + router.push("/(tabs)/drill/new")} + /> + ) + } + /> + + setActionDrill(undefined)} + onMakeActive={() => { + const drill = actionDrill; + setActionDrill(undefined); + if (drill) { + void controller.makeActive(drill).catch(() => undefined); + } + }} + onRename={beginRename} + onDelete={beginDelete} + /> + setRenameDrill(undefined)} + onSave={async (name) => { + if (!renameDrill) return; + await controller.rename(renameDrill, name); + setRenameDrill(undefined); + }} + /> + ); } diff --git a/apps/mobile/src/features/drill/drill-management.ts b/apps/mobile/src/features/drill/drill-management.ts new file mode 100644 index 00000000..b0d6141a --- /dev/null +++ b/apps/mobile/src/features/drill/drill-management.ts @@ -0,0 +1,68 @@ +import type { Drill, DrillRepository } from "@eight2five/mobile/drill"; + +export const DRILL_NAME_MAX_LENGTH = 80; + +export interface DrillListEntry { + readonly drill: Drill; + readonly pageCount: number; +} + +export function normalizeDrillName(value: string): string { + return value.trim(); +} + +export function validateDrillName(value: string): string | undefined { + const name = normalizeDrillName(value); + if (!name) return "Enter a drill name."; + if (name.length > DRILL_NAME_MAX_LENGTH) { + return `Drill names must be ${DRILL_NAME_MAX_LENGTH} characters or fewer.`; + } + return undefined; +} + +export async function loadDrillList( + repository: DrillRepository, +): Promise { + const drills = await repository.listDrills(); + const pageCounts = await Promise.all( + drills.map(async (drill) => (await repository.listPages(drill.id)).length), + ); + // Preserve Thread 1's repository-defined deterministic ordering. + return drills.map((drill, index) => ({ + drill, + pageCount: pageCounts[index], + })); +} + +export async function createNamedDrill( + repository: DrillRepository, + value: string, +): Promise { + const error = validateDrillName(value); + if (error) throw new Error(error); + return await repository.createDrill(normalizeDrillName(value)); +} + +export async function renameNamedDrill( + repository: DrillRepository, + drillId: string, + value: string, +): Promise { + const error = validateDrillName(value); + if (error) throw new Error(error); + return await repository.renameDrill(drillId, normalizeDrillName(value)); +} + +export async function deleteDrillAndRefreshSettings( + repository: DrillRepository, + drillId: string, + reloadSettings: () => Promise, +): Promise { + await repository.deleteDrill(drillId); + // SQLite foreign keys clear active/selected pointers; publish that snapshot. + await reloadSettings(); +} + +export function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/features/drill/use-drill-editor-controller.ts b/apps/mobile/src/features/drill/use-drill-editor-controller.ts index 53929292..a2900761 100644 --- a/apps/mobile/src/features/drill/use-drill-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-editor-controller.ts @@ -1,15 +1,136 @@ -import { getDrillTerms } from "@eight2five/mobile/drill"; +import React from "react"; +import { useFocusEffect } from "expo-router"; +import { + getDrillTerms, + type Drill, + type DrillPage, +} from "@eight2five/mobile/drill"; -import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { + createNamedDrill, + deleteDrillAndRefreshSettings, + renameNamedDrill, + toError, +} from "./drill-management"; -/** Shared controller boundary for create and existing-drill routes. */ export function useDrillEditorController(drillId?: string) { const snapshot = useAppSettingsSnapshot(); + const store = useAppSettingsStore(); + const [drill, setDrill] = React.useState(); + const [pages, setPages] = React.useState([]); + const [loading, setLoading] = React.useState(Boolean(drillId)); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(); + const operationInFlight = React.useRef(false); + + const refresh = React.useCallback(async () => { + if (!drillId || snapshot.status !== "ready") return; + try { + const repository = store.getDrillRepository(); + const [nextDrill, nextPages] = await Promise.all([ + repository.getDrill(drillId), + repository.listPages(drillId), + ]); + if (!nextDrill) throw new Error("This drill no longer exists."); + setDrill(nextDrill); + setPages(nextPages); + setError(undefined); + } catch (cause) { + setError(toError(cause)); + } finally { + setLoading(false); + } + }, [drillId, snapshot.status, store]); + + useFocusEffect( + React.useCallback(() => { + void refresh(); + }, [refresh]), + ); + + const saveName = React.useCallback( + async (name: string) => { + if (operationInFlight.current) { + throw new Error("A save is already in progress."); + } + operationInFlight.current = true; + setSaving(true); + setError(undefined); + try { + const repository = store.getDrillRepository(); + const saved = drillId + ? await renameNamedDrill(repository, drillId, name) + : await createNamedDrill(repository, name); + setDrill(saved); + return saved; + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + operationInFlight.current = false; + setSaving(false); + } + }, + [drillId, store], + ); + + const makeActive = React.useCallback(async () => { + if (!drillId) return; + if (operationInFlight.current) return; + operationInFlight.current = true; + setSaving(true); + setError(undefined); + try { + await store.setActiveDrill(drillId); + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + operationInFlight.current = false; + setSaving(false); + } + }, [drillId, store]); + + const remove = React.useCallback(async () => { + if (!drillId) return; + if (operationInFlight.current) return; + operationInFlight.current = true; + setSaving(true); + setError(undefined); + try { + await deleteDrillAndRefreshSettings( + store.getDrillRepository(), + drillId, + () => store.reload(), + ); + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + operationInFlight.current = false; + setSaving(false); + } + }, [drillId, store]); + return { drillId, - status: snapshot.status, - settings: snapshot.settings, + drill, + pages, + loading: snapshot.status === "loading" || loading, + saving, + active: snapshot.settings.activeDrillId === drillId, terms: getDrillTerms(snapshot.settings.drillTerminology), - error: snapshot.error, + error: error ?? snapshot.error, + refresh, + saveName, + makeActive, + remove, } as const; } diff --git a/apps/mobile/src/features/drill/use-drill-list-controller.ts b/apps/mobile/src/features/drill/use-drill-list-controller.ts index 1eeb9800..e019a32a 100644 --- a/apps/mobile/src/features/drill/use-drill-list-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-list-controller.ts @@ -1,14 +1,127 @@ -import { getDrillTerms } from "@eight2five/mobile/drill"; +import React from "react"; +import { useFocusEffect } from "expo-router"; +import { + getDrillTerms, + type Drill, + type DrillRepository, +} from "@eight2five/mobile/drill"; -import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { + deleteDrillAndRefreshSettings, + loadDrillList, + renameNamedDrill, + toError, + type DrillListEntry, +} from "./drill-management"; -/** Initial route-level contract; persistence actions are added in Phase 2. */ export function useDrillListController() { const snapshot = useAppSettingsSnapshot(); + const store = useAppSettingsStore(); + const [entries, setEntries] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(); + const [busyDrillId, setBusyDrillId] = React.useState(); + const mutationInFlight = React.useRef(false); + + const refresh = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + try { + setEntries(await loadDrillList(store.getDrillRepository())); + setError(undefined); + } catch (cause) { + setError(toError(cause)); + } finally { + setLoading(false); + } + }, [snapshot.status, store]); + + useFocusEffect( + React.useCallback(() => { + void refresh(); + }, [refresh]), + ); + + const mutate = React.useCallback( + async ( + drillId: string, + operation: (repository: DrillRepository) => Promise, + ) => { + if (mutationInFlight.current) { + throw new Error("Another drill update is in progress."); + } + mutationInFlight.current = true; + setBusyDrillId(drillId); + setError(undefined); + try { + const result = await operation(store.getDrillRepository()); + await refresh(); + return result; + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + mutationInFlight.current = false; + setBusyDrillId(undefined); + } + }, + [refresh, store], + ); + + const rename = React.useCallback( + async (drill: Drill, name: string) => + await mutate(drill.id, (repository) => + renameNamedDrill(repository, drill.id, name), + ), + [mutate], + ); + + const makeActive = React.useCallback( + async (drill: Drill) => { + if (mutationInFlight.current) { + throw new Error("Another drill update is in progress."); + } + mutationInFlight.current = true; + setBusyDrillId(drill.id); + setError(undefined); + try { + await store.setActiveDrill(drill.id); + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + mutationInFlight.current = false; + setBusyDrillId(undefined); + } + }, + [store], + ); + + const remove = React.useCallback( + async (drill: Drill) => + await mutate(drill.id, async (repository) => { + await deleteDrillAndRefreshSettings(repository, drill.id, () => + store.reload(), + ); + }), + [mutate, store], + ); + return { - status: snapshot.status, - settings: snapshot.settings, + entries, + loading: snapshot.status === "loading" || loading, + error: error ?? snapshot.error, + busyDrillId, + activeDrillId: snapshot.settings.activeDrillId, terms: getDrillTerms(snapshot.settings.drillTerminology), - error: snapshot.error, + refresh, + rename, + makeActive, + remove, } as const; } From 6ea225c8ac0c6e6a811f4c240faf638f16c2f16a Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 22:19:50 -0500 Subject: [PATCH 009/101] feat(drill): add manual page coordinate entry --- .../(tabs)/drill/[drillId]/page/[pageId].tsx | 13 +- .../drill/__tests__/page-form.test.ts | 181 ++++++++ .../components/marching-coordinate-form.tsx | 402 ++++++++++++++++++ .../features/drill/drill-editor-screen.tsx | 15 +- .../src/features/drill/page-editor-screen.tsx | 99 ++++- apps/mobile/src/features/drill/page-form.ts | 263 ++++++++++++ .../src/features/drill/page-management.ts | 57 +++ .../drill/use-page-editor-controller.ts | 117 ++++- 8 files changed, 1130 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/drill/__tests__/page-form.test.ts create mode 100644 apps/mobile/src/features/drill/components/marching-coordinate-form.tsx create mode 100644 apps/mobile/src/features/drill/page-form.ts create mode 100644 apps/mobile/src/features/drill/page-management.ts diff --git a/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx b/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx index aa2ec0fb..565e9561 100644 --- a/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx +++ b/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx @@ -3,9 +3,18 @@ import { useLocalSearchParams } from "expo-router"; import { PageEditorScreen } from "../../../../../src/features/drill/page-editor-screen"; export default function DrillPageRoute() { - const { drillId, pageId } = useLocalSearchParams<{ + const { drillId, pageId, placement, relativePageId } = useLocalSearchParams<{ drillId: string; pageId: string; + placement?: "append" | "before" | "after"; + relativePageId?: string; }>(); - return ; + return ( + + ); } diff --git a/apps/mobile/src/features/drill/__tests__/page-form.test.ts b/apps/mobile/src/features/drill/__tests__/page-form.test.ts new file mode 100644 index 00000000..204d9610 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/page-form.test.ts @@ -0,0 +1,181 @@ +import type { DrillRepository } from "@eight2five/mobile/drill"; +import { + formatMarchingFrontBack, + formatMarchingSide, + marchingCoordinateToFieldPoint, +} from "@eight2five/mobile/field"; + +import { + createDefaultPageDraft, + pageToDraft, + validatePageDraft, + type MarchingCoordinateDraft, +} from "../page-form"; +import { savePageDraft } from "../page-management"; + +const VALID_DRAFT: MarchingCoordinateDraft = { + label: "31A", + countsFromPrevious: "16", + side: "2", + yardLine: "40", + sideRelation: "inside", + sideOffsetSteps: "2.25", + frontBackReference: "front-hash", + frontBackRelation: "in-front-of", + frontBackOffsetSteps: "4.5", +}; + +describe("structured marching coordinate form", () => { + test("defaults the first entry to zero counts without copying a coordinate", () => { + expect( + createDefaultPageDraft({ ordinal: 0, suggestedLabel: "1" }), + ).toMatchObject({ + label: "1", + countsFromPrevious: "0", + side: "center", + yardLine: "50", + sideRelation: "on", + frontBackReference: "front-sideline", + frontBackRelation: "on", + }); + expect( + createDefaultPageDraft({ ordinal: 2, suggestedLabel: "New" }) + .countsFromPrevious, + ).toBe("8"); + }); + + test("converts structured fractional controls to one canonical FieldPoint", () => { + const result = validatePageDraft(VALID_DRAFT); + expect(result.errors).toEqual({}); + expect(result.value).toBeDefined(); + expect(formatMarchingSide(result.value!.coordinate.side)).toBe( + "Side 2: 2.25 Steps inside 40 yd ln", + ); + expect(formatMarchingFrontBack(result.value!.coordinate.frontBack)).toBe( + "4.5 Steps in front of HS FH", + ); + expect(result.value!.countsFromPrevious).toBe(16); + }); + + test("initializes controls through inverse conversion and round trips", () => { + const position = marchingCoordinateToFieldPoint({ + side: { side: 1, yardLine: 35, relation: "outside", offsetSteps: 1.25 }, + frontBack: { + reference: "back-hash", + relation: "behind", + offsetSteps: 3.75, + }, + }); + const draft = pageToDraft({ + label: "Finale", + countsFromPrevious: 12, + position, + }); + const roundTrip = validatePageDraft(draft); + + expect(draft).toMatchObject({ + label: "Finale", + countsFromPrevious: "12", + side: "1", + yardLine: "35", + sideRelation: "outside", + frontBackReference: "back-hash", + frontBackRelation: "behind", + }); + expect(roundTrip.value?.position.xMeters).toBeCloseTo(position.xMeters, 10); + expect(roundTrip.value?.position.yMeters).toBeCloseTo(position.yMeters, 10); + }); + + test("normalizes zero offsets and the exact 50 to On with no side", () => { + const result = validatePageDraft({ + ...VALID_DRAFT, + side: "1", + yardLine: "50", + sideRelation: "outside", + sideOffsetSteps: "0", + frontBackRelation: "behind", + frontBackOffsetSteps: "0", + }); + + expect(result.value?.coordinate.side).toEqual({ + side: "center", + yardLine: 50, + relation: "on", + offsetSteps: 0, + }); + expect(result.value?.coordinate.frontBack.relation).toBe("on"); + }); + + test("returns actionable metadata, numeric, relation, and bounds errors", () => { + expect( + validatePageDraft({ + ...VALID_DRAFT, + label: " ", + countsFromPrevious: "-1", + }).errors, + ).toMatchObject({ + label: expect.stringContaining("label"), + countsFromPrevious: expect.stringContaining("non-negative"), + }); + expect( + validatePageDraft({ + ...VALID_DRAFT, + side: "center", + yardLine: "45", + }).errors.side, + ).toContain("50-yard line"); + expect( + validatePageDraft({ + ...VALID_DRAFT, + side: "1", + yardLine: "0", + sideRelation: "outside", + sideOffsetSteps: "0.25", + }).errors.coordinate, + ).toContain("field bounds"); + }); + + test("persists canonical create and edit payloads", async () => { + const createdPage = { + id: "page-new", + drillId: "drill", + ordinal: 0, + label: "31A", + countsFromPrevious: 16, + position: validatePageDraft(VALID_DRAFT).value!.position, + }; + const repository = { + createPage: jest.fn(async () => createdPage), + updatePage: jest.fn(async () => createdPage), + } as unknown as DrillRepository; + + await savePageDraft({ + repository, + drillId: "drill", + pageId: "new", + pages: [], + placement: "append", + draft: VALID_DRAFT, + }); + expect(repository.createPage).toHaveBeenCalledWith({ + drillId: "drill", + label: "31A", + countsFromPrevious: 16, + position: createdPage.position, + }); + + await savePageDraft({ + repository, + drillId: "drill", + pageId: "page-new", + pages: [createdPage], + placement: "append", + draft: VALID_DRAFT, + }); + expect(repository.updatePage).toHaveBeenCalledWith("page-new", { + label: "31A", + countsFromPrevious: 16, + position: createdPage.position, + }); + }); +}); diff --git a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx new file mode 100644 index 00000000..0b0f28e5 --- /dev/null +++ b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx @@ -0,0 +1,402 @@ +import React from "react"; +import { ChevronDown } from "lucide-react-native"; +import { Card } from "@eight2five/ui/components/card"; +import { + FormControl, + FormControlError, + FormControlErrorText, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { + Select, + SelectBackdrop, + SelectContent, + SelectDragIndicator, + SelectDragIndicatorWrapper, + SelectIcon, + SelectInput, + SelectItem, + SelectPortal, + SelectTrigger, +} from "@eight2five/ui/components/select"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { + PAGE_LABEL_MAX_LENGTH, + YARD_LINES, + previewCoordinate, + validatePageDraft, + type MarchingCoordinateDraft, +} from "../page-form"; + +const SIDE_CHOICES = [ + { label: "Side 1", value: "1" }, + { label: "Side 2", value: "2" }, + { label: "No side (on 50)", value: "center" }, +] as const; + +const SIDE_RELATION_CHOICES = [ + { label: "On", value: "on" }, + { label: "Inside", value: "inside" }, + { label: "Outside", value: "outside" }, +] as const; + +const FRONT_BACK_CHOICES = [ + { label: "Front Sideline", value: "front-sideline" }, + { label: "HS FH", value: "front-hash" }, + { label: "HS BH", value: "back-hash" }, + { label: "Back Sideline", value: "back-sideline" }, +] as const; + +const FRONT_BACK_RELATION_CHOICES = [ + { label: "On", value: "on" }, + { label: "In front of", value: "in-front-of" }, + { label: "Behind", value: "behind" }, +] as const; + +export function MarchingCoordinateForm({ + draft, + terminologySingular, + disabled, + onChange, +}: { + draft: MarchingCoordinateDraft; + terminologySingular: string; + disabled: boolean; + onChange(draft: MarchingCoordinateDraft): void; +}) { + const theme = useEight2FiveTheme(); + const validation = validatePageDraft(draft); + const preview = previewCoordinate(draft); + const update = ( + key: Key, + value: MarchingCoordinateDraft[Key], + ) => onChange({ ...draft, [key]: value }); + + return ( + + + update("label", value)} + /> + update("countsFromPrevious", value)} + /> + + + + { + if (value === "center") { + onChange({ + ...draft, + side: "center", + yardLine: "50", + sideRelation: "on", + sideOffsetSteps: "0", + }); + } else { + update("side", value); + } + }} + /> + ({ + label: yardLine === 0 ? "Goal Line" : `${yardLine} yard line`, + value: String(yardLine), + }))} + error={validation.errors.yardLine} + disabled={disabled} + onChange={(value) => { + const leavingCenter = value !== "50" && draft.side === "center"; + const exactlyOnFifty = + value === "50" && Number(draft.sideOffsetSteps) === 0; + onChange({ + ...draft, + yardLine: value, + side: leavingCenter + ? "1" + : exactlyOnFifty + ? "center" + : draft.side, + ...(exactlyOnFifty + ? { sideRelation: "on", sideOffsetSteps: "0" } + : {}), + }); + }} + /> + + onChange({ + ...draft, + sideRelation: value, + ...(value === "on" ? { sideOffsetSteps: "0" } : {}), + }) + } + /> + + onChange({ + ...draft, + sideOffsetSteps: value, + ...(Number(value) === 0 ? { sideRelation: "on" } : {}), + }) + } + /> + + + + update("frontBackReference", value)} + /> + + onChange({ + ...draft, + frontBackRelation: value, + ...(value === "on" ? { frontBackOffsetSteps: "0" } : {}), + }) + } + /> + + onChange({ + ...draft, + frontBackOffsetSteps: value, + ...(Number(value) === 0 ? { frontBackRelation: "on" } : {}), + }) + } + /> + + + + + Live coordinate preview + + {preview ? ( + <> + {preview.side} + {preview.frontBack} + + ) : ( + + Complete a valid in-bounds coordinate to preview it. + + )} + {validation.errors.coordinate ? ( + + {validation.errors.coordinate} + + ) : null} + + + ); +} + +function FormSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + const theme = useEight2FiveTheme(); + return ( + + + {title} + + + {children} + + + ); +} + +function TextField({ + label, + value, + error, + helper, + disabled, + numeric = false, + maxLength, + onChangeText, +}: { + label: string; + value: string; + error?: string; + helper?: string; + disabled: boolean; + numeric?: boolean; + maxLength?: number; + onChangeText(value: string): void; +}) { + return ( + + + {label} + + + + + {helper ? ( + + {helper} + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} + +function SelectField< + Value extends MarchingCoordinateDraft[keyof MarchingCoordinateDraft], +>({ + label, + value, + choices, + error, + disabled, + onChange, +}: { + label: string; + value: Value; + choices: readonly { readonly label: string; readonly value: Value }[]; + error?: string; + disabled: boolean; + onChange(value: Value): void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + {label} + + + {error ? ( + + {error} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/drill/drill-editor-screen.tsx b/apps/mobile/src/features/drill/drill-editor-screen.tsx index 2be6bd6e..d30acc8e 100644 --- a/apps/mobile/src/features/drill/drill-editor-screen.tsx +++ b/apps/mobile/src/features/drill/drill-editor-screen.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useRouter } from "expo-router"; -import { Check, Pencil, Trash2 } from "lucide-react-native"; +import { Check, Pencil, Plus, Trash2 } from "lucide-react-native"; import { Button, ButtonIcon, @@ -123,6 +123,19 @@ export function DrillEditorScreen({ drillId }: { drillId?: string }) { + {!controller.active ? ( + + + + ); } diff --git a/apps/mobile/src/features/drill/page-form.ts b/apps/mobile/src/features/drill/page-form.ts new file mode 100644 index 00000000..e5e7655f --- /dev/null +++ b/apps/mobile/src/features/drill/page-form.ts @@ -0,0 +1,263 @@ +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + fieldPointToMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, + marchingCoordinateToFieldPoint, + type FieldLateralReference, + type FieldPoint, + type MarchingCoordinate, + type MarchingFrontBackRelation, + type MarchingSideReference, + type MarchingSideRelation, +} from "@eight2five/mobile/field"; + +export const PAGE_LABEL_MAX_LENGTH = 40; +export const YARD_LINES = Object.freeze( + Array.from({ length: 11 }, (_, index) => index * 5), +); + +export interface MarchingCoordinateDraft { + readonly label: string; + readonly countsFromPrevious: string; + readonly side: "1" | "2" | "center"; + readonly yardLine: string; + readonly sideRelation: MarchingSideRelation; + readonly sideOffsetSteps: string; + readonly frontBackReference: FieldLateralReference; + readonly frontBackRelation: MarchingFrontBackRelation; + readonly frontBackOffsetSteps: string; +} + +export type PageFormField = + | "label" + | "countsFromPrevious" + | "side" + | "yardLine" + | "sideOffsetSteps" + | "frontBackOffsetSteps" + | "coordinate"; + +export type PageFormErrors = Partial>; + +export interface ValidatedPageDraft { + readonly label: string; + readonly countsFromPrevious: number; + readonly position: FieldPoint; + readonly coordinate: MarchingCoordinate; +} + +export interface PageDraftValidation { + readonly errors: PageFormErrors; + readonly value?: ValidatedPageDraft; +} + +export interface CoordinatePreview { + readonly side: string; + readonly frontBack: string; +} + +export function createDefaultPageDraft({ + ordinal, + suggestedLabel, +}: { + ordinal: number; + suggestedLabel: string; +}): MarchingCoordinateDraft { + return { + label: suggestedLabel, + countsFromPrevious: ordinal === 0 ? "0" : "8", + side: "center", + yardLine: "50", + sideRelation: "on", + sideOffsetSteps: "0", + frontBackReference: "front-sideline", + frontBackRelation: "on", + frontBackOffsetSteps: "0", + }; +} + +export function pageToDraft(page: { + readonly label: string; + readonly countsFromPrevious: number; + readonly position: FieldPoint; +}): MarchingCoordinateDraft { + const coordinate = fieldPointToMarchingCoordinate(page.position); + return { + label: page.label, + countsFromPrevious: String(page.countsFromPrevious), + side: String(coordinate.side.side) as MarchingCoordinateDraft["side"], + yardLine: String(coordinate.side.yardLine), + sideRelation: coordinate.side.relation, + sideOffsetSteps: String(coordinate.side.offsetSteps), + frontBackReference: coordinate.frontBack.reference, + frontBackRelation: coordinate.frontBack.relation, + frontBackOffsetSteps: String(coordinate.frontBack.offsetSteps), + }; +} + +export function validatePageDraft( + draft: MarchingCoordinateDraft, +): PageDraftValidation { + const errors: PageFormErrors = {}; + const label = draft.label.trim(); + if (!label) errors.label = "Enter a label."; + else if (label.length > PAGE_LABEL_MAX_LENGTH) { + errors.label = `Labels must be ${PAGE_LABEL_MAX_LENGTH} characters or fewer.`; + } + + const counts = parseNonNegativeNumber( + draft.countsFromPrevious, + "Enter finite, non-negative counts.", + ); + if (typeof counts === "string") errors.countsFromPrevious = counts; + + const coordinateResult = coordinateFromDraft(draft); + Object.assign(errors, coordinateResult.errors); + if ( + Object.keys(errors).length > 0 || + typeof counts === "string" || + !coordinateResult.coordinate || + !coordinateResult.position + ) { + return { errors }; + } + + return { + errors, + value: { + label, + countsFromPrevious: counts, + coordinate: coordinateResult.coordinate, + position: coordinateResult.position, + }, + }; +} + +export function previewCoordinate( + draft: MarchingCoordinateDraft, +): CoordinatePreview | undefined { + const result = coordinateFromDraft(draft); + if (!result.coordinate || Object.keys(result.errors).length > 0) { + return undefined; + } + return { + side: formatMarchingSide(result.coordinate.side), + frontBack: formatMarchingFrontBack(result.coordinate.frontBack), + }; +} + +function coordinateFromDraft(draft: MarchingCoordinateDraft): { + readonly errors: PageFormErrors; + readonly coordinate?: MarchingCoordinate; + readonly position?: FieldPoint; +} { + const errors: PageFormErrors = {}; + const yardLine = parseNonNegativeNumber( + draft.yardLine, + "Choose a five-yard line.", + ); + if (typeof yardLine === "string" || !YARD_LINES.includes(yardLine)) { + errors.yardLine = "Choose a five-yard line from 0 through 50."; + } + + const sideOffset = parseNonNegativeNumber( + draft.sideOffsetSteps, + "Enter a finite, non-negative side offset.", + ); + if (typeof sideOffset === "string") errors.sideOffsetSteps = sideOffset; + + const frontBackOffset = parseNonNegativeNumber( + draft.frontBackOffsetSteps, + "Enter a finite, non-negative front-to-back offset.", + ); + if (typeof frontBackOffset === "string") { + errors.frontBackOffsetSteps = frontBackOffset; + } + + if ( + typeof yardLine === "string" || + typeof sideOffset === "string" || + typeof frontBackOffset === "string" + ) { + return { errors }; + } + + const side = parseSide(draft.side); + if (side === undefined) { + errors.side = "Choose Side 1, Side 2, or no side for the 50."; + return { errors }; + } + + if (side === "center" && yardLine !== 50) { + errors.side = "No side is available only when exactly on the 50-yard line."; + } + + const normalizedSide = yardLine === 50 && sideOffset === 0 ? "center" : side; + const normalizedSideRelation = sideOffset === 0 ? "on" : draft.sideRelation; + if ( + yardLine === 50 && + normalizedSide !== "center" && + normalizedSideRelation !== "outside" + ) { + errors.coordinate = + "An offset from the 50-yard line must be outside on Side 1 or Side 2."; + } + + // The domain preserves arbitrary fractional steps, so validation never rounds + // entered offsets; quarter-step values remain fully supported. + const coordinate: MarchingCoordinate = { + side: { + side: normalizedSide, + yardLine, + relation: normalizedSide === "center" ? "on" : normalizedSideRelation, + offsetSteps: normalizedSide === "center" ? 0 : sideOffset, + }, + frontBack: { + reference: draft.frontBackReference, + relation: frontBackOffset === 0 ? "on" : draft.frontBackRelation, + offsetSteps: frontBackOffset, + }, + }; + + if (Object.keys(errors).length > 0) return { errors, coordinate }; + try { + const position = marchingCoordinateToFieldPoint(coordinate); + if (!isInFieldBounds(position)) { + errors.coordinate = "The coordinate must remain within the field bounds."; + return { errors, coordinate }; + } + return { errors, coordinate, position }; + } catch (cause) { + errors.coordinate = cause instanceof Error ? cause.message : String(cause); + return { errors, coordinate }; + } +} + +function parseNonNegativeNumber( + value: string, + message: string, +): number | string { + if (!value.trim()) return message; + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : message; +} + +function parseSide( + value: MarchingCoordinateDraft["side"], +): MarchingSideReference | undefined { + if (value === "1") return 1; + if (value === "2") return 2; + return value === "center" ? "center" : undefined; +} + +function isInFieldBounds(point: FieldPoint): boolean { + const { bounds } = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + const epsilon = 1e-8; + return ( + point.xMeters >= bounds.minXMeters - epsilon && + point.xMeters <= bounds.maxXMeters + epsilon && + point.yMeters >= bounds.minYMeters - epsilon && + point.yMeters <= bounds.maxYMeters + epsilon + ); +} diff --git a/apps/mobile/src/features/drill/page-management.ts b/apps/mobile/src/features/drill/page-management.ts new file mode 100644 index 00000000..00c0fc34 --- /dev/null +++ b/apps/mobile/src/features/drill/page-management.ts @@ -0,0 +1,57 @@ +import type { DrillPage, DrillRepository } from "@eight2five/mobile/drill"; + +import { validatePageDraft, type MarchingCoordinateDraft } from "./page-form"; + +export type PagePlacement = "append" | "before" | "after"; + +export function getPageCreationOrdinal( + pages: readonly DrillPage[], + placement: PagePlacement, + relativePageId?: string, +): number { + if (placement === "append") return pages.length; + const relativeIndex = pages.findIndex((page) => page.id === relativePageId); + if (relativeIndex < 0) { + throw new Error("The selected insertion point no longer exists."); + } + return placement === "before" ? relativeIndex : relativeIndex + 1; +} + +export async function savePageDraft({ + repository, + drillId, + pageId, + pages, + placement, + relativePageId, + draft, +}: { + repository: DrillRepository; + drillId: string; + pageId: string; + pages: readonly DrillPage[]; + placement: PagePlacement; + relativePageId?: string; + draft: MarchingCoordinateDraft; +}): Promise { + const validation = validatePageDraft(draft); + if (!validation.value) { + const message = + Object.values(validation.errors)[0] ?? "Review the page form."; + throw new Error(message); + } + const details = { + label: validation.value.label, + countsFromPrevious: validation.value.countsFromPrevious, + position: validation.value.position, + }; + if (pageId !== "new") { + return await repository.updatePage(pageId, details); + } + + const ordinal = getPageCreationOrdinal(pages, placement, relativePageId); + if (placement === "append") { + return await repository.createPage({ drillId, ...details }); + } + return await repository.insertPage(drillId, ordinal, details); +} diff --git a/apps/mobile/src/features/drill/use-page-editor-controller.ts b/apps/mobile/src/features/drill/use-page-editor-controller.ts index adb4c9b8..56536711 100644 --- a/apps/mobile/src/features/drill/use-page-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-page-editor-controller.ts @@ -1,15 +1,120 @@ -import { getDrillTerms } from "@eight2five/mobile/drill"; +import React from "react"; +import { useFocusEffect } from "expo-router"; +import { getDrillTerms, type DrillPage } from "@eight2five/mobile/drill"; -import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { toError } from "./drill-management"; +import { + createDefaultPageDraft, + pageToDraft, + type MarchingCoordinateDraft, +} from "./page-form"; +import { + getPageCreationOrdinal, + savePageDraft, + type PagePlacement, +} from "./page-management"; -/** Controller boundary for append, insert, and existing-page edit routes. */ -export function usePageEditorController(drillId: string, pageId: string) { +export function usePageEditorController( + drillId: string, + pageId: string, + placement: PagePlacement = "append", + relativePageId?: string, +) { const snapshot = useAppSettingsSnapshot(); + const store = useAppSettingsStore(); + const [page, setPage] = React.useState(); + const [pages, setPages] = React.useState([]); + const [draft, setDraft] = React.useState(); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(); + const saveInFlight = React.useRef(false); + + const refresh = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + try { + const repository = store.getDrillRepository(); + const nextPages = await repository.listPages(drillId); + setPages(nextPages); + if (pageId === "new") { + const ordinal = getPageCreationOrdinal( + nextPages, + placement, + relativePageId, + ); + // Count-based labels are only an editable suggestion; existing labels + // are never parsed or assumed to form a numeric sequence. + setDraft( + createDefaultPageDraft({ + ordinal, + suggestedLabel: String(nextPages.length + 1), + }), + ); + setPage(undefined); + } else { + const nextPage = await repository.getPage(pageId); + if (!nextPage || nextPage.drillId !== drillId) { + throw new Error("This drill entry no longer exists in the drill."); + } + setPage(nextPage); + setDraft(pageToDraft(nextPage)); + } + setError(undefined); + } catch (cause) { + setError(toError(cause)); + } finally { + setLoading(false); + } + }, [drillId, pageId, placement, relativePageId, snapshot.status, store]); + + useFocusEffect( + React.useCallback(() => { + void refresh(); + }, [refresh]), + ); + + const save = React.useCallback(async () => { + if (!draft) throw new Error("The page form is not ready."); + if (saveInFlight.current) throw new Error("A save is already in progress."); + saveInFlight.current = true; + setSaving(true); + setError(undefined); + try { + const saved = await savePageDraft({ + repository: store.getDrillRepository(), + drillId, + pageId, + pages, + placement, + relativePageId, + draft, + }); + setPage(saved); + return saved; + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + saveInFlight.current = false; + setSaving(false); + } + }, [draft, drillId, pageId, pages, placement, relativePageId, store]); + return { drillId, pageId, - status: snapshot.status, + page, + draft, + setDraft, + loading: snapshot.status === "loading" || loading, + saving, terms: getDrillTerms(snapshot.settings.drillTerminology), - error: snapshot.error, + error: error ?? snapshot.error, + save, } as const; } From 9ec44a7823bcae157710118bdfab65f295a7fab4 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 22:32:06 -0500 Subject: [PATCH 010/101] feat(drill): add page ordering and transition analysis --- .../(tabs)/drill/[drillId]/page/[pageId].tsx | 3 +- .../drill/__tests__/page-ordering.test.ts | 164 +++++++++ .../drill/components/drill-page-actions.tsx | 93 +++++ .../drill/components/drill-page-list-item.tsx | 157 +++++++++ .../drill/components/transition-summary.tsx | 60 ++++ .../features/drill/drill-editor-screen.tsx | 325 ++++++++++++------ .../src/features/drill/page-management.ts | 40 +++ .../features/drill/transition-presentation.ts | 46 +++ .../drill/use-drill-editor-controller.ts | 91 +++++ 9 files changed, 871 insertions(+), 108 deletions(-) create mode 100644 apps/mobile/src/features/drill/__tests__/page-ordering.test.ts create mode 100644 apps/mobile/src/features/drill/components/drill-page-actions.tsx create mode 100644 apps/mobile/src/features/drill/components/drill-page-list-item.tsx create mode 100644 apps/mobile/src/features/drill/components/transition-summary.tsx create mode 100644 apps/mobile/src/features/drill/transition-presentation.ts diff --git a/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx b/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx index 565e9561..301eaafd 100644 --- a/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx +++ b/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx @@ -1,6 +1,7 @@ import { useLocalSearchParams } from "expo-router"; import { PageEditorScreen } from "../../../../../src/features/drill/page-editor-screen"; +import { normalizePagePlacement } from "../../../../../src/features/drill/page-management"; export default function DrillPageRoute() { const { drillId, pageId, placement, relativePageId } = useLocalSearchParams<{ @@ -13,7 +14,7 @@ export default function DrillPageRoute() { ); diff --git a/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts b/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts new file mode 100644 index 00000000..036bd246 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts @@ -0,0 +1,164 @@ +import type { + DrillPage, + DrillRepository, + TransitionAnalysis, +} from "@eight2five/mobile/drill"; +import { yardsToMeters } from "@eight2five/mobile/field"; + +import { + formatTransitionAnalysis, + getTransitionPresentation, +} from "../transition-presentation"; +import { createDefaultPageDraft } from "../page-form"; +import { + deletePageAndRefreshSettings, + getPageCreationOrdinal, + movePage, + normalizePagePlacement, + reorderedPageIds, + savePageDraft, +} from "../page-management"; + +function page(id: string, ordinal: number, xYards = ordinal * 5): DrillPage { + return { + id, + drillId: "drill", + ordinal, + label: id.toUpperCase(), + countsFromPrevious: ordinal === 0 ? 0 : 8, + position: { xMeters: yardsToMeters(xYards), yMeters: 0 }, + }; +} + +describe("page ordering and transition presentation", () => { + const pages = [page("a", 0), page("b", 1), page("c", 2)]; + + test("calculates append and insertion ordinals without parsing labels", () => { + expect(normalizePagePlacement("before")).toBe("before"); + expect(normalizePagePlacement("after")).toBe("after"); + expect(normalizePagePlacement("malformed-deep-link")).toBe("append"); + expect(getPageCreationOrdinal(pages, "append")).toBe(3); + expect(getPageCreationOrdinal(pages, "before", "b")).toBe(1); + expect(getPageCreationOrdinal(pages, "after", "b")).toBe(2); + expect(() => getPageCreationOrdinal(pages, "before", "missing")).toThrow( + "insertion point", + ); + }); + + test("inserts before and after through the transactional repository contract", async () => { + const inserted = page("inserted", 1); + const repository = { + insertPage: jest.fn(async () => inserted), + } as unknown as DrillRepository; + const draft = createDefaultPageDraft({ ordinal: 1, suggestedLabel: "X" }); + + await savePageDraft({ + repository, + drillId: "drill", + pageId: "new", + pages, + placement: "before", + relativePageId: "b", + draft, + }); + expect(repository.insertPage).toHaveBeenLastCalledWith( + "drill", + 1, + expect.objectContaining({ label: "X" }), + ); + + await savePageDraft({ + repository, + drillId: "drill", + pageId: "new", + pages, + placement: "after", + relativePageId: "b", + draft, + }); + expect(repository.insertPage).toHaveBeenLastCalledWith( + "drill", + 2, + expect.objectContaining({ label: "X" }), + ); + }); + + test("moves stable IDs up and down and leaves boundaries unchanged", async () => { + expect(reorderedPageIds(pages, "b", "up")).toEqual(["b", "a", "c"]); + expect(reorderedPageIds(pages, "b", "down")).toEqual(["a", "c", "b"]); + expect(reorderedPageIds(pages, "a", "up")).toBeUndefined(); + expect(() => reorderedPageIds(pages, "missing", "up")).toThrow( + "no longer exists", + ); + + const reordered = [page("b", 0), page("a", 1), page("c", 2)]; + const repository = { + reorderPages: jest.fn(async () => reordered), + } as unknown as DrillRepository; + await expect(movePage(repository, "drill", pages, "b", "up")).resolves.toBe( + reordered, + ); + expect(repository.reorderPages).toHaveBeenCalledWith("drill", [ + "b", + "a", + "c", + ]); + }); + + test("deletes before publishing cleared selected-page state", async () => { + const order: string[] = []; + const repository = { + deletePage: jest.fn(async () => { + order.push("delete"); + }), + } as unknown as DrillRepository; + await deletePageAndRefreshSettings(repository, "b", async () => { + order.push("reload"); + }); + expect(order).toEqual(["delete", "reload"]); + }); + + test("formats unavailable, Halt, Step Size, and xCounts values", () => { + const base: TransitionAnalysis = { + distanceSteps: 8, + stepSizeToFive: 6.5, + isHalt: false, + yardLineCrossingCounts: [4, 12], + }; + expect(formatTransitionAnalysis(base, false, 16)).toEqual({ + stepSize: "–", + crossingCounts: "–", + }); + expect(formatTransitionAnalysis(base, true, 0)).toEqual({ + stepSize: "–", + crossingCounts: "–", + }); + expect(formatTransitionAnalysis(base, true, 16)).toEqual({ + stepSize: "6.5 to 5", + crossingCounts: "4, 12", + }); + expect( + formatTransitionAnalysis( + { ...base, isHalt: true, stepSizeToFive: undefined }, + true, + 16, + ).stepSize, + ).toBe("Halt"); + }); + + test("recalculates both transitions neighboring a changed middle page", () => { + const originalMiddle = getTransitionPresentation(pages[0], pages[1]); + const originalFollowing = getTransitionPresentation(pages[1], pages[2]); + const changedMiddle = { + ...pages[1], + position: pages[0].position, + }; + const nextMiddle = getTransitionPresentation(pages[0], changedMiddle); + const nextFollowing = getTransitionPresentation(changedMiddle, pages[2]); + + expect(originalMiddle.stepSize).toBe("8 to 5"); + expect(originalFollowing.stepSize).toBe("8 to 5"); + expect(nextMiddle.stepSize).toBe("Halt"); + expect(nextFollowing.stepSize).toBe("4 to 5"); + }); +}); diff --git a/apps/mobile/src/features/drill/components/drill-page-actions.tsx b/apps/mobile/src/features/drill/components/drill-page-actions.tsx new file mode 100644 index 00000000..0e171732 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-page-actions.tsx @@ -0,0 +1,93 @@ +import { Alert } from "react-native"; +import { Flag, Pencil, Plus, Trash2 } from "lucide-react-native"; +import type { DrillPage, DrillTerms } from "@eight2five/mobile/drill"; +import { + Actionsheet, + ActionsheetBackdrop, + ActionsheetContent, + ActionsheetDragIndicator, + ActionsheetDragIndicatorWrapper, + ActionsheetIcon, + ActionsheetItem, + ActionsheetItemText, +} from "@eight2five/ui/components/actionsheet"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; + +export function confirmDeletePage( + page: DrillPage, + terms: DrillTerms, + onConfirm: () => void, +) { + Alert.alert( + `Delete ${terms.singular} ${page.label}?`, + `This permanently deletes the ${terms.lowercaseSingular}.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Delete", style: "destructive", onPress: onConfirm }, + ], + ); +} + +export function DrillPageActionsSheet({ + page, + terms, + drillActive, + selected, + onClose, + onSelect, + onEdit, + onInsertBefore, + onInsertAfter, + onDelete, +}: { + page?: DrillPage; + terms: DrillTerms; + drillActive: boolean; + selected: boolean; + onClose(): void; + onSelect(): void; + onEdit(): void; + onInsertBefore(): void; + onInsertAfter(): void; + onDelete(): void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + + + + {drillActive && !selected ? ( + + + Select {terms.singular} + + ) : null} + + + Edit {terms.singular} + + + + + Insert {terms.lowercaseSingular} before + + + + + + Insert {terms.lowercaseSingular} after + + + + + + Delete {terms.singular} + + + + + ); +} diff --git a/apps/mobile/src/features/drill/components/drill-page-list-item.tsx b/apps/mobile/src/features/drill/components/drill-page-list-item.tsx new file mode 100644 index 00000000..2bd8c45d --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-page-list-item.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { ArrowDown, ArrowUp, EllipsisVertical } from "lucide-react-native"; +import type { DrillPage, DrillTerms } from "@eight2five/mobile/drill"; +import { + fieldPointToMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, +} from "@eight2five/mobile/field"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Card } from "@eight2five/ui/components/card"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { TransitionSummary } from "./transition-summary"; + +export const DrillPageListItem = React.memo(function DrillPageListItem({ + page, + previousPage, + terms, + selected, + busy, + first, + last, + onEdit, + onMoveUp, + onMoveDown, + onOpenActions, +}: { + page: DrillPage; + previousPage?: DrillPage; + terms: DrillTerms; + selected: boolean; + busy: boolean; + first: boolean; + last: boolean; + onEdit(): void; + onMoveUp(): void; + onMoveDown(): void; + onOpenActions(): void; +}) { + const theme = useEight2FiveTheme(); + const coordinate = React.useMemo( + () => fieldPointToMarchingCoordinate(page.position), + [page.position], + ); + const side = formatMarchingSide(coordinate.side); + const frontBack = formatMarchingFrontBack(coordinate.frontBack); + const title = `${terms.singular} ${page.label}`; + + return ( + + + + + + + {title} + + + {page.countsFromPrevious} counts + + {selected ? ( + + Selected + + ) : null} + + {side} + {frontBack} + + + + + + + + + + + + + ); +}); diff --git a/apps/mobile/src/features/drill/components/transition-summary.tsx b/apps/mobile/src/features/drill/components/transition-summary.tsx new file mode 100644 index 00000000..3d9ee8d6 --- /dev/null +++ b/apps/mobile/src/features/drill/components/transition-summary.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { analyzeTransition, type DrillPage } from "@eight2five/mobile/drill"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Text } from "@eight2five/ui/components/text"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { formatTransitionAnalysis } from "../transition-presentation"; + +export const TransitionSummary = React.memo(function TransitionSummary({ + previousPage, + page, +}: { + previousPage?: DrillPage; + page: DrillPage; +}) { + const theme = useEight2FiveTheme(); + const currentX = page.position.xMeters; + const currentY = page.position.yMeters; + const previousX = previousPage?.position.xMeters; + const previousY = previousPage?.position.yMeters; + const counts = page.countsFromPrevious; + const presentation = React.useMemo( + () => + formatTransitionAnalysis( + analyzeTransition( + previousX === undefined || previousY === undefined + ? undefined + : { xMeters: previousX, yMeters: previousY }, + { xMeters: currentX, yMeters: currentY }, + counts, + ), + previousX !== undefined && previousY !== undefined, + counts, + ), + [counts, currentX, currentY, previousX, previousY], + ); + + return ( + + + Step Size: {presentation.stepSize} + + + xCounts: {presentation.crossingCounts} + + + ); +}); diff --git a/apps/mobile/src/features/drill/drill-editor-screen.tsx b/apps/mobile/src/features/drill/drill-editor-screen.tsx index d30acc8e..c7370d99 100644 --- a/apps/mobile/src/features/drill/drill-editor-screen.tsx +++ b/apps/mobile/src/features/drill/drill-editor-screen.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useRouter } from "expo-router"; import { Check, Pencil, Plus, Trash2 } from "lucide-react-native"; +import type { DrillPage } from "@eight2five/mobile/drill"; import { Button, ButtonIcon, @@ -8,6 +9,7 @@ import { ButtonText, } from "@eight2five/ui/components/button"; import { Card } from "@eight2five/ui/components/card"; +import { FlatList } from "@eight2five/ui/components/flat-list"; import { Heading } from "@eight2five/ui/components/heading"; import { ScrollView } from "@eight2five/ui/components/scroll-view"; import { Text } from "@eight2five/ui/components/text"; @@ -21,6 +23,11 @@ import { import { SettingsMessage } from "../settings/settings-components"; import { confirmDeleteDrill } from "./components/destructive-drill-actions"; +import { + DrillPageActionsSheet, + confirmDeletePage, +} from "./components/drill-page-actions"; +import { DrillPageListItem } from "./components/drill-page-list-item"; import { DrillNameDialog } from "./components/drill-name-dialog"; import { DrillNameForm } from "./components/drill-name-form"; import { useDrillEditorController } from "./use-drill-editor-controller"; @@ -30,6 +37,41 @@ export function DrillEditorScreen({ drillId }: { drillId?: string }) { const theme = useEight2FiveTheme(); const controller = useDrillEditorController(drillId); const [renaming, setRenaming] = React.useState(false); + const [actionPage, setActionPage] = React.useState(); + + const openPage = React.useCallback( + (page: DrillPage) => { + if (!drillId) return; + router.push({ + pathname: "/(tabs)/drill/[drillId]/page/[pageId]", + params: { drillId, pageId: page.id }, + }); + }, + [drillId, router], + ); + + const renderPage = React.useCallback( + ({ item, index }: { item: DrillPage; index: number }) => ( + openPage(item)} + onMoveUp={() => { + void controller.move(item, "up").catch(() => undefined); + }} + onMoveDown={() => { + void controller.move(item, "down").catch(() => undefined); + }} + onOpenActions={() => setActionPage(item)} + /> + ), + [controller, openPage], + ); if (!drillId) { return ( @@ -70,118 +112,187 @@ export function DrillEditorScreen({ drillId }: { drillId?: string }) { }); }; + const insertRelativeToActionPage = (placement: "before" | "after") => { + const page = actionPage; + setActionPage(undefined); + if (!page) return; + router.push({ + pathname: "/(tabs)/drill/[drillId]/page/[pageId]", + params: { + drillId, + pageId: "new", + placement, + relativePageId: page.id, + }, + }); + }; + + const deleteActionPage = () => { + const page = actionPage; + setActionPage(undefined); + if (!page) return; + confirmDeletePage(page, controller.terms, () => { + void controller.removePage(page).catch(() => undefined); + }); + }; + return ( - - {controller.loading ? ( - Loading drill… - ) : null} - {controller.error ? ( - - {controller.error.message} - - ) : null} - {drill ? ( - <> - - - {drill.name} - - - {controller.pages.length}{" "} - {controller.pages.length === 1 - ? controller.terms.singular - : controller.terms.plural} - - - {controller.active ? "Active" : "Inactive"} - - + + page.id} + renderItem={renderPage} + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ + flexGrow: 1, + gap: eight2FiveSpacing.sm, + padding: eight2FiveSpacing.md, + paddingBottom: eight2FiveSpacing.xxl, + }} + ListHeaderComponent={ + + {controller.loading ? ( + Loading drill… + ) : null} + {controller.error ? ( + + {controller.error.message} + + ) : null} + {drill ? ( + <> + + + {drill.name} + + + {controller.pages.length}{" "} + {controller.pages.length === 1 + ? controller.terms.singular + : controller.terms.plural} + + + {controller.active ? "Active" : "Inactive"} + + - - - {!controller.active ? ( - + + + {!controller.active ? ( + + ) : null} + + + + + {controller.terms.plural} + + ) : null} - - + } + ListEmptyComponent={ + !controller.loading && drill ? ( + + No {controller.terms.lowercasePlural} yet. Add one to begin. + + ) : null + } + /> - setRenaming(false)} - onSave={async (name) => { - await controller.saveName(name); - setRenaming(false); - }} - /> - + {drill ? ( + setRenaming(false)} + onSave={async (name) => { + await controller.saveName(name); + setRenaming(false); + }} + /> ) : null} - + setActionPage(undefined)} + onSelect={() => { + const page = actionPage; + setActionPage(undefined); + if (page) void controller.selectPage(page).catch(() => undefined); + }} + onEdit={() => { + const page = actionPage; + setActionPage(undefined); + if (page) openPage(page); + }} + onInsertBefore={() => insertRelativeToActionPage("before")} + onInsertAfter={() => insertRelativeToActionPage("after")} + onDelete={deleteActionPage} + /> + ); } diff --git a/apps/mobile/src/features/drill/page-management.ts b/apps/mobile/src/features/drill/page-management.ts index 00c0fc34..6efd2b19 100644 --- a/apps/mobile/src/features/drill/page-management.ts +++ b/apps/mobile/src/features/drill/page-management.ts @@ -3,6 +3,11 @@ import type { DrillPage, DrillRepository } from "@eight2five/mobile/drill"; import { validatePageDraft, type MarchingCoordinateDraft } from "./page-form"; export type PagePlacement = "append" | "before" | "after"; +export type PageMoveDirection = "up" | "down"; + +export function normalizePagePlacement(value: unknown): PagePlacement { + return value === "before" || value === "after" ? value : "append"; +} export function getPageCreationOrdinal( pages: readonly DrillPage[], @@ -55,3 +60,38 @@ export async function savePageDraft({ } return await repository.insertPage(drillId, ordinal, details); } + +export function reorderedPageIds( + pages: readonly DrillPage[], + pageId: string, + direction: PageMoveDirection, +): readonly string[] | undefined { + const index = pages.findIndex((page) => page.id === pageId); + if (index < 0) throw new Error("The page to move no longer exists."); + const destination = direction === "up" ? index - 1 : index + 1; + if (destination < 0 || destination >= pages.length) return undefined; + const ids = pages.map((page) => page.id); + [ids[index], ids[destination]] = [ids[destination], ids[index]]; + return ids; +} + +export async function movePage( + repository: DrillRepository, + drillId: string, + pages: readonly DrillPage[], + pageId: string, + direction: PageMoveDirection, +): Promise { + const ids = reorderedPageIds(pages, pageId, direction); + return ids ? await repository.reorderPages(drillId, ids) : pages; +} + +export async function deletePageAndRefreshSettings( + repository: DrillRepository, + pageId: string, + reloadSettings: () => Promise, +): Promise { + await repository.deletePage(pageId); + // Publish the selected-page pointer cleared by SQLite's foreign key. + await reloadSettings(); +} diff --git a/apps/mobile/src/features/drill/transition-presentation.ts b/apps/mobile/src/features/drill/transition-presentation.ts new file mode 100644 index 00000000..5a5966da --- /dev/null +++ b/apps/mobile/src/features/drill/transition-presentation.ts @@ -0,0 +1,46 @@ +import { + analyzeDrillTransition, + type DrillPage, + type TransitionAnalysis, +} from "@eight2five/mobile/drill"; + +export interface TransitionPresentation { + readonly stepSize: string; + readonly crossingCounts: string; +} + +export function formatTransitionAnalysis( + analysis: TransitionAnalysis, + hasPreviousPage: boolean, + countsFromPrevious: number, +): TransitionPresentation { + if (!hasPreviousPage || countsFromPrevious === 0) { + return { stepSize: "–", crossingCounts: "–" }; + } + return { + stepSize: analysis.isHalt + ? "Halt" + : analysis.stepSizeToFive === undefined + ? "–" + : `${formatMetricNumber(analysis.stepSizeToFive)} to 5`, + crossingCounts: + analysis.yardLineCrossingCounts.length > 0 + ? analysis.yardLineCrossingCounts.map(formatMetricNumber).join(", ") + : "–", + }; +} + +export function getTransitionPresentation( + previousPage: DrillPage | undefined, + page: DrillPage, +): TransitionPresentation { + return formatTransitionAnalysis( + analyzeDrillTransition(previousPage, page), + Boolean(previousPage), + page.countsFromPrevious, + ); +} + +function formatMetricNumber(value: number): string { + return Number(value.toFixed(6)).toString(); +} diff --git a/apps/mobile/src/features/drill/use-drill-editor-controller.ts b/apps/mobile/src/features/drill/use-drill-editor-controller.ts index a2900761..1eb19fc2 100644 --- a/apps/mobile/src/features/drill/use-drill-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-editor-controller.ts @@ -16,6 +16,11 @@ import { renameNamedDrill, toError, } from "./drill-management"; +import { + deletePageAndRefreshSettings, + movePage, + type PageMoveDirection, +} from "./page-management"; export function useDrillEditorController(drillId?: string) { const snapshot = useAppSettingsSnapshot(); @@ -24,6 +29,7 @@ export function useDrillEditorController(drillId?: string) { const [pages, setPages] = React.useState([]); const [loading, setLoading] = React.useState(Boolean(drillId)); const [saving, setSaving] = React.useState(false); + const [busyPageId, setBusyPageId] = React.useState(); const [error, setError] = React.useState(); const operationInFlight = React.useRef(false); @@ -119,18 +125,103 @@ export function useDrillEditorController(drillId?: string) { } }, [drillId, store]); + const selectPage = React.useCallback( + async (page: DrillPage) => { + if (snapshot.settings.activeDrillId !== drillId) { + const operationError = new Error( + "Make this drill active before selecting one of its entries.", + ); + setError(operationError); + throw operationError; + } + if (operationInFlight.current) return; + operationInFlight.current = true; + setBusyPageId(page.id); + setError(undefined); + try { + await store.setSelectedDrillPage(page.id); + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + operationInFlight.current = false; + setBusyPageId(undefined); + } + }, + [drillId, snapshot.settings.activeDrillId, store], + ); + + const move = React.useCallback( + async (page: DrillPage, direction: PageMoveDirection) => { + if (!drillId || operationInFlight.current) return; + operationInFlight.current = true; + setBusyPageId(page.id); + setError(undefined); + try { + setPages( + await movePage( + store.getDrillRepository(), + drillId, + pages, + page.id, + direction, + ), + ); + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + operationInFlight.current = false; + setBusyPageId(undefined); + } + }, + [drillId, pages, store], + ); + + const removePage = React.useCallback( + async (page: DrillPage) => { + if (!drillId || operationInFlight.current) return; + operationInFlight.current = true; + setBusyPageId(page.id); + setError(undefined); + try { + await deletePageAndRefreshSettings( + store.getDrillRepository(), + page.id, + () => store.reload(), + ); + setPages(await store.getDrillRepository().listPages(drillId)); + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + operationInFlight.current = false; + setBusyPageId(undefined); + } + }, + [drillId, store], + ); + return { drillId, drill, pages, loading: snapshot.status === "loading" || loading, saving, + busyPageId, active: snapshot.settings.activeDrillId === drillId, + selectedPageId: snapshot.settings.selectedDrillPageId, terms: getDrillTerms(snapshot.settings.drillTerminology), error: error ?? snapshot.error, refresh, saveName, makeActive, remove, + selectPage, + move, + removePage, } as const; } From d93b0e00597cae98be84790d3f2b0d5259b4ed2b Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 22:34:19 -0500 Subject: [PATCH 011/101] chore(drill): stabilize manual drill MVP --- apps/mobile/app/(tabs)/drill/_layout.tsx | 8 +++--- .../__tests__/drill-route-access.test.ts | 25 +++++++++++++++++++ .../drill/components/drill-name-dialog.tsx | 7 ++++++ .../features/drill/drill-editor-screen.tsx | 2 +- .../src/features/drill/drill-route-access.ts | 11 ++++++++ .../src/features/drill/page-management.ts | 4 +-- .../drill/use-page-editor-controller.ts | 2 +- 7 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts create mode 100644 apps/mobile/src/features/drill/drill-route-access.ts diff --git a/apps/mobile/app/(tabs)/drill/_layout.tsx b/apps/mobile/app/(tabs)/drill/_layout.tsx index 3ef37205..c8f01fb7 100644 --- a/apps/mobile/app/(tabs)/drill/_layout.tsx +++ b/apps/mobile/app/(tabs)/drill/_layout.tsx @@ -2,14 +2,16 @@ import { Redirect, Stack } from "expo-router"; import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; import { useAppSettingsSnapshot } from "../../../src/state/app-settings-store"; +import { getDrillRouteAccess } from "../../../src/features/drill/drill-route-access"; export default function DrillLayout() { const theme = useEight2FiveTheme(); const { status, settings } = useAppSettingsSnapshot(); + const access = getDrillRouteAccess(status, settings.drillFeaturesEnabled); // Keep disabled drill routes inaccessible even when opened from a stale link. - if (status === "loading") return null; - if (status === "error" || !settings.drillFeaturesEnabled) { + if (access === "loading") return null; + if (access === "redirect") { return ; } @@ -31,7 +33,7 @@ export default function DrillLayout() {
); diff --git a/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts b/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts new file mode 100644 index 00000000..69d94d81 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts @@ -0,0 +1,25 @@ +import { getDrillTerms } from "@eight2five/mobile/drill"; + +import { getDrillRouteAccess } from "../drill-route-access"; + +describe("drill route and terminology behavior", () => { + test("does not expose disabled or failed drill routes", () => { + expect(getDrillRouteAccess("loading", true)).toBe("loading"); + expect(getDrillRouteAccess("ready", true)).toBe("allowed"); + expect(getDrillRouteAccess("ready", false)).toBe("redirect"); + expect(getDrillRouteAccess("error", true)).toBe("redirect"); + }); + + test("keeps Pages and Sets as display-only terminology", () => { + expect(getDrillTerms("pages")).toMatchObject({ + singular: "Page", + plural: "Pages", + lowercaseSingular: "page", + }); + expect(getDrillTerms("sets")).toMatchObject({ + singular: "Set", + plural: "Sets", + lowercaseSingular: "set", + }); + }); +}); diff --git a/apps/mobile/src/features/drill/components/drill-name-dialog.tsx b/apps/mobile/src/features/drill/components/drill-name-dialog.tsx index 3ccdcbd1..389f4f80 100644 --- a/apps/mobile/src/features/drill/components/drill-name-dialog.tsx +++ b/apps/mobile/src/features/drill/components/drill-name-dialog.tsx @@ -3,9 +3,11 @@ import { ModalBackdrop, ModalBody, ModalContent, + ModalFooter, ModalHeader, } from "@eight2five/ui/components/modal"; import { Heading } from "@eight2five/ui/components/heading"; +import { Button, ButtonText } from "@eight2five/ui/components/button"; import { DrillNameForm } from "./drill-name-form"; @@ -38,6 +40,11 @@ export function DrillNameDialog({ onSubmit={onSave} /> + + + ); diff --git a/apps/mobile/src/features/drill/drill-editor-screen.tsx b/apps/mobile/src/features/drill/drill-editor-screen.tsx index c7370d99..a5b1cfe2 100644 --- a/apps/mobile/src/features/drill/drill-editor-screen.tsx +++ b/apps/mobile/src/features/drill/drill-editor-screen.tsx @@ -91,7 +91,7 @@ export function DrillEditorScreen({ drillId }: { drillId?: string }) { { const created = await controller.saveName(name); router.replace(`/(tabs)/drill/${created.id}`); diff --git a/apps/mobile/src/features/drill/drill-route-access.ts b/apps/mobile/src/features/drill/drill-route-access.ts new file mode 100644 index 00000000..bc273288 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-route-access.ts @@ -0,0 +1,11 @@ +import type { AppSettingsStoreStatus } from "../../state/app-settings-store"; + +export type DrillRouteAccess = "loading" | "allowed" | "redirect"; + +export function getDrillRouteAccess( + status: AppSettingsStoreStatus, + drillFeaturesEnabled: boolean, +): DrillRouteAccess { + if (status === "loading") return "loading"; + return status === "ready" && drillFeaturesEnabled ? "allowed" : "redirect"; +} diff --git a/apps/mobile/src/features/drill/page-management.ts b/apps/mobile/src/features/drill/page-management.ts index 6efd2b19..95d2b57c 100644 --- a/apps/mobile/src/features/drill/page-management.ts +++ b/apps/mobile/src/features/drill/page-management.ts @@ -42,7 +42,7 @@ export async function savePageDraft({ const validation = validatePageDraft(draft); if (!validation.value) { const message = - Object.values(validation.errors)[0] ?? "Review the page form."; + Object.values(validation.errors)[0] ?? "Review the entry form."; throw new Error(message); } const details = { @@ -67,7 +67,7 @@ export function reorderedPageIds( direction: PageMoveDirection, ): readonly string[] | undefined { const index = pages.findIndex((page) => page.id === pageId); - if (index < 0) throw new Error("The page to move no longer exists."); + if (index < 0) throw new Error("The entry to move no longer exists."); const destination = direction === "up" ? index - 1 : index + 1; if (destination < 0 || destination >= pages.length) return undefined; const ids = pages.map((page) => page.id); diff --git a/apps/mobile/src/features/drill/use-page-editor-controller.ts b/apps/mobile/src/features/drill/use-page-editor-controller.ts index 56536711..b5019bb9 100644 --- a/apps/mobile/src/features/drill/use-page-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-page-editor-controller.ts @@ -78,7 +78,7 @@ export function usePageEditorController( ); const save = React.useCallback(async () => { - if (!draft) throw new Error("The page form is not ready."); + if (!draft) throw new Error("The entry form is not ready."); if (saveInFlight.current) throw new Error("A save is already in progress."); saveInFlight.current = true; setSaving(true); From 6c5c1454f9d5900e89ed719fb89bdfbb105f1060 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 23:05:44 -0500 Subject: [PATCH 012/101] feat(field): add Skia field and camera --- packages/mobile/package.json | 1 + .../field/__tests__/field-camera-math.test.ts | 108 +++++++ .../src/field/__tests__/field-paths.test.ts | 140 +++++++++ .../src/field/camera/field-camera-math.ts | 162 ++++++++++ .../src/field/camera/field-camera-policy.ts | 80 +++++ .../src/field/camera/field-camera-types.ts | 35 +++ .../src/field/camera/use-field-gestures.ts | 229 ++++++++++++++ packages/mobile/src/field/index.ts | 5 + .../src/field/render/create-field-paths.ts | 291 ++++++++++++++++++ .../mobile/src/field/render/field-canvas.tsx | 140 +++++++++ .../src/field/render/field-render-tokens.ts | 29 ++ .../mobile/src/field/render/field-scene.tsx | 51 +++ .../src/field/render/field-static-layer.tsx | 104 +++++++ packages/mobile/src/field/render/index.ts | 3 + packages/mobile/src/index.ts | 5 + .../pans-manager/pans-network-grid-camera.ts | 35 ++- 16 files changed, 1404 insertions(+), 14 deletions(-) create mode 100644 packages/mobile/src/field/__tests__/field-camera-math.test.ts create mode 100644 packages/mobile/src/field/__tests__/field-paths.test.ts create mode 100644 packages/mobile/src/field/camera/field-camera-math.ts create mode 100644 packages/mobile/src/field/camera/field-camera-policy.ts create mode 100644 packages/mobile/src/field/camera/field-camera-types.ts create mode 100644 packages/mobile/src/field/camera/use-field-gestures.ts create mode 100644 packages/mobile/src/field/render/create-field-paths.ts create mode 100644 packages/mobile/src/field/render/field-canvas.tsx create mode 100644 packages/mobile/src/field/render/field-render-tokens.ts create mode 100644 packages/mobile/src/field/render/field-scene.tsx create mode 100644 packages/mobile/src/field/render/field-static-layer.tsx create mode 100644 packages/mobile/src/field/render/index.ts diff --git a/packages/mobile/package.json b/packages/mobile/package.json index f0563864..5df955ad 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -8,6 +8,7 @@ ".": "./src/index.ts", "./pans-manager": "./src/pans-manager/index.ts", "./field": "./src/field/index.ts", + "./field/render": "./src/field/render/index.ts", "./drill": "./src/drill/index.ts", "./settings": "./src/settings/index.ts", "./storage": "./src/storage/index.ts" diff --git a/packages/mobile/src/field/__tests__/field-camera-math.test.ts b/packages/mobile/src/field/__tests__/field-camera-math.test.ts new file mode 100644 index 00000000..435011b5 --- /dev/null +++ b/packages/mobile/src/field/__tests__/field-camera-math.test.ts @@ -0,0 +1,108 @@ +import { + applyFieldCameraTransform, + clampFieldViewport, + createFieldPanBaseline, + fieldCameraTransform, + fieldCenterForStationaryWorldPoint, + fieldPanCenter, + fieldScreenToWorld, + fieldWorldToScreen, +} from "../camera/field-camera-math"; +import { + FIELD_MIN_METERS_PER_PIXEL, + getFieldCameraBounds, + getFieldGridBounds, + getFieldMaximumMetersPerPixel, +} from "../camera/field-camera-policy"; + +const size = { width: 800, height: 400 }; +const viewport = { + centerXMeters: 45, + centerYMeters: 20, + metersPerPixel: 0.1, +}; + +describe("field camera math", () => { + test("round-trips world and screen points and matches the Skia transform", () => { + const point = { xMeters: 49.25, yMeters: 18.5 }; + const screen = fieldWorldToScreen(point, viewport, size); + + expect(fieldScreenToWorld(screen, viewport, size)).toEqual(point); + expect( + applyFieldCameraTransform(point, fieldCameraTransform(viewport, size)), + ).toEqual(screen); + }); + + test("preserves the world point beneath a pinch focal point", () => { + const focal = { x: 155, y: 92 }; + const world = fieldScreenToWorld(focal, viewport, size); + const nextScale = 0.05; + const center = fieldCenterForStationaryWorldPoint( + world, + focal, + size, + nextScale, + ); + + const preserved = fieldWorldToScreen( + world, + { + centerXMeters: center.xMeters, + centerYMeters: center.yMeters, + metersPerPixel: nextScale, + }, + size, + ); + expect(preserved.x).toBeCloseTo(focal.x, 10); + expect(preserved.y).toBeCloseTo(focal.y, 10); + }); + + test("clamps using the visible half span and centers oversized viewports", () => { + const bounds = { + minXMeters: 0, + maxXMeters: 100, + minYMeters: 0, + maxYMeters: 50, + }; + expect( + clampFieldViewport( + { centerXMeters: -50, centerYMeters: 100, metersPerPixel: 0.1 }, + size, + bounds, + ), + ).toEqual({ + centerXMeters: 40, + centerYMeters: 30, + metersPerPixel: 0.1, + }); + expect( + clampFieldViewport( + { centerXMeters: 10, centerYMeters: 10, metersPerPixel: 1 }, + size, + bounds, + ), + ).toMatchObject({ centerXMeters: 50, centerYMeters: 25 }); + }); + + test("rebases pan translation after a pinch pointer transition", () => { + const current = { xMeters: 30, yMeters: 12 }; + const rebased = createFieldPanBaseline(current, 84, -20, 0.1); + + expect(fieldPanCenter(rebased, 84, -20)).toEqual(current); + expect(fieldPanCenter(rebased, 94, -15)).toEqual({ + xMeters: 29, + yMeters: 12.5, + }); + }); + + test("keeps zoom limits centralized around the padded field", () => { + const gridBounds = getFieldGridBounds(); + const cameraBounds = getFieldCameraBounds(); + const maximum = getFieldMaximumMetersPerPixel(size, gridBounds); + + expect(FIELD_MIN_METERS_PER_PIXEL).toBe(0.02); + expect(maximum).toBeGreaterThan(FIELD_MIN_METERS_PER_PIXEL); + expect(cameraBounds.minXMeters).toBeLessThan(gridBounds.minXMeters); + expect(cameraBounds.maxYMeters).toBeGreaterThan(gridBounds.maxYMeters); + }); +}); diff --git a/packages/mobile/src/field/__tests__/field-paths.test.ts b/packages/mobile/src/field/__tests__/field-paths.test.ts new file mode 100644 index 00000000..e9b6fe86 --- /dev/null +++ b/packages/mobile/src/field/__tests__/field-paths.test.ts @@ -0,0 +1,140 @@ +import { createFieldPaths } from "../render/create-field-paths"; +import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "../template"; +import { STANDARD_STEP_METERS, yardsToMeters } from "../units"; + +const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + +describe("aggregate field paths", () => { + test("returns one immutable, memoized path set per template", () => { + const first = createFieldPaths(field); + const second = createFieldPaths(field); + + expect(second).toBe(first); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.fieldExtent)).toBe(true); + expect(Object.isFrozen(first.gridExtent)).toBe(true); + expect(Object.isFrozen(first.counts)).toBe(true); + expect(Object.isFrozen(first.counts.stepGrid)).toBe(true); + }); + + test("exposes exact field and ten-yard padded grid extents", () => { + const paths = createFieldPaths(field); + const paddingMeters = yardsToMeters(10); + + expect(paths.fieldExtent).toEqual(field.bounds); + expect(paths.gridExtent).toEqual({ + minXMeters: field.bounds.minXMeters - paddingMeters, + maxXMeters: field.bounds.maxXMeters + paddingMeters, + minYMeters: field.bounds.minYMeters - paddingMeters, + maxYMeters: field.bounds.maxYMeters + paddingMeters, + }); + expect(paths.extents).toEqual({ + field: paths.fieldExtent, + grid: paths.gridExtent, + }); + expect(paths.stepGridSpacingMeters).toBe(STANDARD_STEP_METERS); + expect(paths.counts.stepGrid.spacingMeters).toBe(STANDARD_STEP_METERS); + }); + + test("clips the fixed-spacing step grid at the exact padded extent", () => { + const paths = createFieldPaths(field); + const { minXMeters, maxXMeters, minYMeters, maxYMeters } = paths.gridExtent; + + expect(paths.stepGridPath).toContain( + segment(minXMeters, minYMeters, minXMeters, maxYMeters), + ); + expect(paths.stepGridPath).toContain( + segment(minXMeters, minYMeters, maxXMeters, minYMeters), + ); + expect(paths.stepGridPath.split(" M ").length).toBe( + paths.counts.stepGrid.verticalLineCount + + paths.counts.stepGrid.horizontalLineCount, + ); + + const verticalX = Array.from( + paths.stepGridPath.matchAll(/M (-?\d+(?:\.\d+)?) /g), + (match) => Number(match[1]), + ).slice(0, paths.counts.stepGrid.verticalLineCount); + for (let index = 1; index < verticalX.length; index += 1) { + expect(verticalX[index] - verticalX[index - 1]).toBeCloseTo( + STANDARD_STEP_METERS, + 6, + ); + } + }); + + test("clips the five-yard grid to the field and includes both axes", () => { + const paths = createFieldPaths(field); + const coordinates = parseCoordinates(paths.fiveYardGridPath); + + for (const { xMeters, yMeters } of coordinates) { + expect(xMeters).toBeGreaterThanOrEqual(field.bounds.minXMeters); + expect(xMeters).toBeLessThanOrEqual(field.bounds.maxXMeters); + expect(yMeters).toBeGreaterThanOrEqual(field.bounds.minYMeters); + expect(yMeters).toBeLessThanOrEqual(field.bounds.maxYMeters); + } + + expect(paths.counts.fiveYardGrid).toMatchObject({ + spacingMeters: yardsToMeters(5), + verticalSubdivisionCount: 21, + horizontalSubdivisionCount: 11, + segmentCount: 32, + clippedToField: true, + }); + expect(paths.fiveYardGridPath).toContain( + segment( + field.bounds.minXMeters, + field.bounds.minYMeters, + field.bounds.maxXMeters, + field.bounds.minYMeters, + ), + ); + }); + + test("keeps football marks aggregate and exposes stable shape counts", () => { + const paths = createFieldPaths(field); + + expect(subpathCount(paths.yardLinesPath)).toBe(19); + expect(paths.counts.yardLines.lineCount).toBe(19); + expect(subpathCount(paths.hashMarksPath)).toBe(198); + expect(paths.counts.hashMarks).toMatchObject({ + rowCount: 2, + ticksPerRow: 99, + tickCount: 198, + spacingMeters: yardsToMeters(1), + }); + expect(subpathCount(paths.boundaryPath)).toBe(1); + expect(paths.boundaryPath.endsWith(" Z")).toBe(true); + expect(paths.counts.boundary.segmentCount).toBe(1); + }); +}); + +function segment( + startXMeters: number, + startYMeters: number, + endXMeters: number, + endYMeters: number, +): string { + return `M ${format(startXMeters)} ${format(startYMeters)} L ${format(endXMeters)} ${format(endYMeters)}`; +} + +function parseCoordinates(path: string): { + xMeters: number; + yMeters: number; +}[] { + const values = path.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []; + const coordinates: { xMeters: number; yMeters: number }[] = []; + for (let index = 0; index + 1 < values.length; index += 2) { + coordinates.push({ xMeters: values[index], yMeters: values[index + 1] }); + } + return coordinates; +} + +function subpathCount(path: string): number { + return path.length === 0 ? 0 : (path.match(/M /g)?.length ?? 0); +} + +function format(value: number): string { + const rounded = Math.round(value * 1_000_000) / 1_000_000; + return String(Object.is(rounded, -0) ? 0 : rounded); +} diff --git a/packages/mobile/src/field/camera/field-camera-math.ts b/packages/mobile/src/field/camera/field-camera-math.ts new file mode 100644 index 00000000..23fbc205 --- /dev/null +++ b/packages/mobile/src/field/camera/field-camera-math.ts @@ -0,0 +1,162 @@ +import type { FieldPoint } from "../types"; +import type { + FieldCamera, + FieldCameraBounds, + FieldPanBaseline, + FieldViewport, + FieldViewportSize, +} from "./field-camera-types"; + +export function setFieldCamera( + camera: FieldCamera, + viewport: FieldViewport, +): void { + "worklet"; + camera.centerXMeters.value = viewport.centerXMeters; + camera.centerYMeters.value = viewport.centerYMeters; + camera.metersPerPixel.value = viewport.metersPerPixel; +} + +export function fieldWorldToScreen( + point: FieldPoint, + viewport: FieldViewport, + size: FieldViewportSize, +): { x: number; y: number } { + "worklet"; + return { + x: + size.width / 2 + + (point.xMeters - viewport.centerXMeters) / viewport.metersPerPixel, + y: + size.height / 2 - + (point.yMeters - viewport.centerYMeters) / viewport.metersPerPixel, + }; +} + +export function fieldScreenToWorld( + point: { readonly x: number; readonly y: number }, + viewport: FieldViewport, + size: FieldViewportSize, +): FieldPoint { + "worklet"; + return { + xMeters: + viewport.centerXMeters + + (point.x - size.width / 2) * viewport.metersPerPixel, + yMeters: + viewport.centerYMeters - + (point.y - size.height / 2) * viewport.metersPerPixel, + }; +} + +export function createFieldPanBaseline( + center: FieldPoint, + translationX: number, + translationY: number, + metersPerPixel: number, +): FieldPanBaseline { + "worklet"; + return { center, translationX, translationY, metersPerPixel }; +} + +/** Uses translation deltas from a baseline so a 1→2→1 pointer change can rebase. */ +export function fieldPanCenter( + baseline: FieldPanBaseline, + translationX: number, + translationY: number, +): FieldPoint { + "worklet"; + return { + xMeters: + baseline.center.xMeters - + (translationX - baseline.translationX) * baseline.metersPerPixel, + yMeters: + baseline.center.yMeters + + (translationY - baseline.translationY) * baseline.metersPerPixel, + }; +} + +export function fieldCenterForStationaryWorldPoint( + worldPoint: FieldPoint, + screenPoint: { readonly x: number; readonly y: number }, + size: FieldViewportSize, + metersPerPixel: number, +): FieldPoint { + "worklet"; + return { + xMeters: + worldPoint.xMeters - (screenPoint.x - size.width / 2) * metersPerPixel, + yMeters: + worldPoint.yMeters + (screenPoint.y - size.height / 2) * metersPerPixel, + }; +} + +export function clampFieldCameraAxis( + center: number, + minimum: number, + maximum: number, + halfVisibleSpan: number, +): number { + "worklet"; + const minimumCenter = minimum + halfVisibleSpan; + const maximumCenter = maximum - halfVisibleSpan; + if (minimumCenter > maximumCenter) return (minimum + maximum) / 2; + return Math.min(maximumCenter, Math.max(minimumCenter, center)); +} + +export function clampFieldViewport( + viewport: FieldViewport, + size: FieldViewportSize, + bounds: FieldCameraBounds, +): FieldViewport { + "worklet"; + const halfWidth = (size.width * viewport.metersPerPixel) / 2; + const halfHeight = (size.height * viewport.metersPerPixel) / 2; + return { + ...viewport, + centerXMeters: clampFieldCameraAxis( + viewport.centerXMeters, + bounds.minXMeters, + bounds.maxXMeters, + halfWidth, + ), + centerYMeters: clampFieldCameraAxis( + viewport.centerYMeters, + bounds.minYMeters, + bounds.maxYMeters, + halfHeight, + ), + }; +} + +export interface FieldCameraTransform { + readonly scaleX: number; + readonly scaleY: number; + readonly translateX: number; + readonly translateY: number; +} + +export function fieldCameraTransform( + viewport: FieldViewport, + size: FieldViewportSize, +): FieldCameraTransform { + "worklet"; + const scale = 1 / viewport.metersPerPixel; + return { + scaleX: scale, + scaleY: -scale, + translateX: size.width / 2 - viewport.centerXMeters * scale, + translateY: size.height / 2 + viewport.centerYMeters * scale, + }; +} + +export function applyFieldCameraTransform( + point: FieldPoint, + transform: FieldCameraTransform, +): { x: number; y: number } { + "worklet"; + return { + x: point.xMeters * transform.scaleX + transform.translateX, + y: point.yMeters * transform.scaleY + transform.translateY, + }; +} diff --git a/packages/mobile/src/field/camera/field-camera-policy.ts b/packages/mobile/src/field/camera/field-camera-policy.ts new file mode 100644 index 00000000..51fa6837 --- /dev/null +++ b/packages/mobile/src/field/camera/field-camera-policy.ts @@ -0,0 +1,80 @@ +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + type StandardHighSchoolFieldTemplate, +} from "../template"; +import { yardsToMeters } from "../units"; +import type { + FieldCameraBounds, + FieldViewport, + FieldViewportSize, +} from "./field-camera-types"; + +export const FIELD_GRID_PERIMETER_YARDS = 10; +export const FIELD_CAMERA_BLANK_MARGIN_YARDS = 5; +export const FIELD_MIN_METERS_PER_PIXEL = 0.02; +export const FIELD_ZOOM_OUT_BREATHING_ROOM = 1.2; +export const FIELD_INITIAL_BREATHING_ROOM = 1.06; + +export function getFieldGridBounds( + template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldCameraBounds { + const padding = yardsToMeters(FIELD_GRID_PERIMETER_YARDS); + return { + minXMeters: template.bounds.minXMeters - padding, + maxXMeters: template.bounds.maxXMeters + padding, + minYMeters: template.bounds.minYMeters - padding, + maxYMeters: template.bounds.maxYMeters + padding, + }; +} + +export function getFieldCameraBounds( + template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldCameraBounds { + const gridBounds = getFieldGridBounds(template); + const margin = yardsToMeters(FIELD_CAMERA_BLANK_MARGIN_YARDS); + return { + minXMeters: gridBounds.minXMeters - margin, + maxXMeters: gridBounds.maxXMeters + margin, + minYMeters: gridBounds.minYMeters - margin, + maxYMeters: gridBounds.maxYMeters + margin, + }; +} + +export function fitFieldBoundsMetersPerPixel( + bounds: FieldCameraBounds, + size: FieldViewportSize, +): number { + "worklet"; + if (size.width <= 0 || size.height <= 0) return FIELD_MIN_METERS_PER_PIXEL; + return Math.max( + (bounds.maxXMeters - bounds.minXMeters) / size.width, + (bounds.maxYMeters - bounds.minYMeters) / size.height, + ); +} + +export function getFieldMaximumMetersPerPixel( + size: FieldViewportSize, + gridBounds: FieldCameraBounds, +): number { + "worklet"; + return Math.max( + FIELD_MIN_METERS_PER_PIXEL, + fitFieldBoundsMetersPerPixel(gridBounds, size) * + FIELD_ZOOM_OUT_BREATHING_ROOM, + ); +} + +export function getInitialFieldViewport( + size: FieldViewportSize, + template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldViewport { + const bounds = getFieldGridBounds(template); + return { + centerXMeters: (bounds.minXMeters + bounds.maxXMeters) / 2, + centerYMeters: (bounds.minYMeters + bounds.maxYMeters) / 2, + metersPerPixel: Math.max( + FIELD_MIN_METERS_PER_PIXEL, + fitFieldBoundsMetersPerPixel(bounds, size) * FIELD_INITIAL_BREATHING_ROOM, + ), + }; +} diff --git a/packages/mobile/src/field/camera/field-camera-types.ts b/packages/mobile/src/field/camera/field-camera-types.ts new file mode 100644 index 00000000..2c8eb078 --- /dev/null +++ b/packages/mobile/src/field/camera/field-camera-types.ts @@ -0,0 +1,35 @@ +import type { SharedValue } from "react-native-reanimated"; + +import type { FieldPoint } from "../types"; + +export interface FieldViewportSize { + readonly width: number; + readonly height: number; +} + +export interface FieldViewport { + readonly centerXMeters: number; + readonly centerYMeters: number; + readonly metersPerPixel: number; +} + +export interface FieldCameraBounds { + readonly minXMeters: number; + readonly maxXMeters: number; + readonly minYMeters: number; + readonly maxYMeters: number; +} + +/** UI-thread camera values. Camera motion must not require a React render. */ +export interface FieldCamera { + readonly centerXMeters: SharedValue; + readonly centerYMeters: SharedValue; + readonly metersPerPixel: SharedValue; +} + +export interface FieldPanBaseline { + readonly center: FieldPoint; + readonly translationX: number; + readonly translationY: number; + readonly metersPerPixel: number; +} diff --git a/packages/mobile/src/field/camera/use-field-gestures.ts b/packages/mobile/src/field/camera/use-field-gestures.ts new file mode 100644 index 00000000..e5be60f5 --- /dev/null +++ b/packages/mobile/src/field/camera/use-field-gestures.ts @@ -0,0 +1,229 @@ +import React from "react"; +import { Gesture } from "react-native-gesture-handler"; +import { + useDerivedValue, + useSharedValue, + type SharedValue, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; + +import type { FieldPoint } from "../types"; +import { + clampFieldCameraAxis, + createFieldPanBaseline, + fieldCenterForStationaryWorldPoint, + fieldPanCenter, + fieldScreenToWorld, + setFieldCamera, +} from "./field-camera-math"; +import { + FIELD_MIN_METERS_PER_PIXEL, + getFieldMaximumMetersPerPixel, +} from "./field-camera-policy"; +import type { + FieldCamera, + FieldCameraBounds, + FieldPanBaseline, + FieldViewport, + FieldViewportSize, +} from "./field-camera-types"; + +interface UseFieldGesturesOptions { + readonly camera: FieldCamera; + readonly canvasSize: SharedValue; + readonly cameraBounds: FieldCameraBounds; + readonly gridBounds: FieldCameraBounds; + readonly onViewportChange?: (viewport: FieldViewport) => void; + readonly testID?: string; +} + +function setSharedValue(sharedValue: SharedValue, value: T): void { + "worklet"; + sharedValue.value = value; +} + +export function useFieldGestures({ + camera, + canvasSize, + cameraBounds, + gridBounds, + onViewportChange, + testID = "field", +}: UseFieldGesturesOptions) { + const panActive = useSharedValue(false); + const pinchActive = useSharedValue(false); + const panNeedsRebase = useSharedValue(true); + const panBaseline = useSharedValue( + createFieldPanBaseline({ xMeters: 0, yMeters: 0 }, 0, 0, 1), + ); + const pinchInitialized = useSharedValue(false); + const pinchStartScale = useSharedValue(1); + const pinchWorldPoint = useSharedValue({ + xMeters: 0, + yMeters: 0, + }); + const interactionActive = useDerivedValue( + () => panActive.value || pinchActive.value, + ); + + const commitViewport = React.useCallback( + (centerXMeters: number, centerYMeters: number, metersPerPixel: number) => { + onViewportChange?.({ centerXMeters, centerYMeters, metersPerPixel }); + }, + [onViewportChange], + ); + + const scheduleCommit = () => { + "worklet"; + scheduleOnRN( + commitViewport, + camera.centerXMeters.value, + camera.centerYMeters.value, + camera.metersPerPixel.value, + ); + }; + + const pan = Gesture.Pan() + .withTestId(`${testID}-pan-gesture`) + .minDistance(2) + .onStart((event) => { + setSharedValue(panActive, true); + setSharedValue(panNeedsRebase, false); + setSharedValue( + panBaseline, + createFieldPanBaseline( + { + xMeters: camera.centerXMeters.value, + yMeters: camera.centerYMeters.value, + }, + event.translationX, + event.translationY, + camera.metersPerPixel.value, + ), + ); + }) + .onUpdate((event) => { + if (event.numberOfPointers !== 1 || pinchActive.value) { + setSharedValue(panNeedsRebase, true); + return; + } + if (panNeedsRebase.value) { + setSharedValue( + panBaseline, + createFieldPanBaseline( + { + xMeters: camera.centerXMeters.value, + yMeters: camera.centerYMeters.value, + }, + event.translationX, + event.translationY, + camera.metersPerPixel.value, + ), + ); + setSharedValue(panNeedsRebase, false); + return; + } + const next = fieldPanCenter( + panBaseline.value, + event.translationX, + event.translationY, + ); + const halfWidth = + (canvasSize.value.width * camera.metersPerPixel.value) / 2; + const halfHeight = + (canvasSize.value.height * camera.metersPerPixel.value) / 2; + setFieldCamera(camera, { + centerXMeters: clampFieldCameraAxis( + next.xMeters, + cameraBounds.minXMeters, + cameraBounds.maxXMeters, + halfWidth, + ), + centerYMeters: clampFieldCameraAxis( + next.yMeters, + cameraBounds.minYMeters, + cameraBounds.maxYMeters, + halfHeight, + ), + metersPerPixel: camera.metersPerPixel.value, + }); + }) + .onEnd(() => { + if (!pinchActive.value) scheduleCommit(); + }) + .onFinalize(() => { + setSharedValue(panActive, false); + setSharedValue(panNeedsRebase, true); + }); + + const pinch = Gesture.Pinch() + .withTestId(`${testID}-pinch-gesture`) + .onStart(() => { + setSharedValue(pinchActive, true); + setSharedValue(pinchInitialized, false); + setSharedValue(panNeedsRebase, true); + }) + .onUpdate((event) => { + if (event.numberOfPointers < 2) return; + const safeScale = Math.max(event.scale, 0.000001); + if (!pinchInitialized.value) { + setSharedValue( + pinchStartScale, + camera.metersPerPixel.value * safeScale, + ); + setSharedValue( + pinchWorldPoint, + fieldScreenToWorld( + { x: event.focalX, y: event.focalY }, + { + centerXMeters: camera.centerXMeters.value, + centerYMeters: camera.centerYMeters.value, + metersPerPixel: camera.metersPerPixel.value, + }, + canvasSize.value, + ), + ); + setSharedValue(pinchInitialized, true); + } + const maximumMetersPerPixel = getFieldMaximumMetersPerPixel( + canvasSize.value, + gridBounds, + ); + const nextScale = Math.min( + maximumMetersPerPixel, + Math.max(FIELD_MIN_METERS_PER_PIXEL, pinchStartScale.value / safeScale), + ); + const next = fieldCenterForStationaryWorldPoint( + pinchWorldPoint.value, + { x: event.focalX, y: event.focalY }, + canvasSize.value, + nextScale, + ); + setFieldCamera(camera, { + centerXMeters: clampFieldCameraAxis( + next.xMeters, + cameraBounds.minXMeters, + cameraBounds.maxXMeters, + (canvasSize.value.width * nextScale) / 2, + ), + centerYMeters: clampFieldCameraAxis( + next.yMeters, + cameraBounds.minYMeters, + cameraBounds.maxYMeters, + (canvasSize.value.height * nextScale) / 2, + ), + metersPerPixel: nextScale, + }); + }) + .onEnd(scheduleCommit) + .onFinalize(() => { + setSharedValue(pinchInitialized, false); + setSharedValue(pinchActive, false); + setSharedValue(panNeedsRebase, true); + }); + + return { + gesture: Gesture.Simultaneous(pan, pinch), + interactionActive, + } as const; +} diff --git a/packages/mobile/src/field/index.ts b/packages/mobile/src/field/index.ts index a4a7eed5..173360b5 100644 --- a/packages/mobile/src/field/index.ts +++ b/packages/mobile/src/field/index.ts @@ -3,3 +3,8 @@ export * from "./units"; export * from "./template"; export * from "./marching"; export * from "./guidance"; +export * from "./camera/field-camera-types"; +export * from "./camera/field-camera-math"; +export * from "./camera/field-camera-policy"; +export * from "./render/create-field-paths"; +export * from "./render/field-render-tokens"; diff --git a/packages/mobile/src/field/render/create-field-paths.ts b/packages/mobile/src/field/render/create-field-paths.ts new file mode 100644 index 00000000..87121a4e --- /dev/null +++ b/packages/mobile/src/field/render/create-field-paths.ts @@ -0,0 +1,291 @@ +import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "../template"; +import type { StandardHighSchoolFieldTemplate } from "../template"; +import { feetToMeters, STANDARD_STEP_METERS, yardsToMeters } from "../units"; + +const GRID_PADDING_YARDS = 10; +const FIVE_YARD_GRID_SPACING_METERS = yardsToMeters(5); +const HASH_MARK_SPACING_METERS = yardsToMeters(1); +const HASH_MARK_LENGTH_METERS = feetToMeters(2); +const PATH_NUMBER_PRECISION = 1_000_000; +const COORDINATE_EPSILON = 1e-9; + +export interface FieldPathExtent { + readonly minXMeters: number; + readonly maxXMeters: number; + readonly minYMeters: number; + readonly maxYMeters: number; +} + +export interface StepGridPathMetadata { + readonly spacingMeters: typeof STANDARD_STEP_METERS; + readonly verticalLineCount: number; + readonly horizontalLineCount: number; +} + +export interface FiveYardGridPathMetadata { + readonly spacingMeters: number; + readonly verticalSubdivisionCount: number; + readonly horizontalSubdivisionCount: number; + readonly segmentCount: number; + readonly clippedToField: true; +} + +export interface YardLinesPathMetadata { + readonly lineCount: number; +} + +export interface HashMarksPathMetadata { + readonly spacingMeters: number; + readonly tickLengthMeters: number; + readonly rowCount: 2; + readonly ticksPerRow: number; + readonly tickCount: number; +} + +export interface BoundaryPathMetadata { + readonly segmentCount: 1; +} + +export interface FieldPathCounts { + readonly stepGrid: StepGridPathMetadata; + readonly fiveYardGrid: FiveYardGridPathMetadata; + readonly yardLines: YardLinesPathMetadata; + readonly hashMarks: HashMarksPathMetadata; + readonly boundary: BoundaryPathMetadata; +} + +/** + * The immutable, world-space SVG geometry consumed by field renderers. + * + * Each path is a single aggregate string rather than a collection of line + * components. Coordinates stay in the field's canonical meter coordinate + * system: X runs from Side 1 to Side 2 and Y runs from the front sideline to + * the back sideline. + */ +export interface FieldPaths { + readonly stepGridPath: string; + readonly fiveYardGridPath: string; + readonly yardLinesPath: string; + readonly hashMarksPath: string; + readonly boundaryPath: string; + readonly fieldExtent: FieldPathExtent; + readonly gridExtent: FieldPathExtent; + readonly stepGridSpacingMeters: typeof STANDARD_STEP_METERS; + readonly extents: { + readonly field: FieldPathExtent; + readonly grid: FieldPathExtent; + }; + readonly counts: FieldPathCounts; + + /** Short aliases keep the path set convenient for drawing callers. */ + readonly stepGrid: string; + readonly fiveYardGrid: string; + readonly yardLines: string; + readonly hashMarks: string; + readonly boundary: string; +} + +const PATH_CACHE = new WeakMap(); + +/** + * Builds all static field geometry in one pass and memoizes it by template. + * The standard template is deeply immutable, so identity-based memoization is + * sufficient and avoids rebuilding hundreds of path segments on every render. + */ +export function createFieldPaths( + template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldPaths { + const cached = PATH_CACHE.get(template); + if (cached) return cached; + + const fieldExtent = freezeExtent({ + minXMeters: template.bounds.minXMeters, + maxXMeters: template.bounds.maxXMeters, + minYMeters: template.bounds.minYMeters, + maxYMeters: template.bounds.maxYMeters, + }); + const gridPaddingMeters = yardsToMeters(GRID_PADDING_YARDS); + const gridExtent = freezeExtent({ + minXMeters: fieldExtent.minXMeters - gridPaddingMeters, + maxXMeters: fieldExtent.maxXMeters + gridPaddingMeters, + minYMeters: fieldExtent.minYMeters - gridPaddingMeters, + maxYMeters: fieldExtent.maxYMeters + gridPaddingMeters, + }); + + const stepGridXCoordinates = coordinatesAtInterval( + gridExtent.minXMeters, + gridExtent.maxXMeters, + STANDARD_STEP_METERS, + ); + const stepGridYCoordinates = coordinatesAtInterval( + gridExtent.minYMeters, + gridExtent.maxYMeters, + STANDARD_STEP_METERS, + ); + const stepGridPath = [ + ...stepGridXCoordinates.map((xMeters) => + verticalSegment(xMeters, gridExtent.minYMeters, gridExtent.maxYMeters), + ), + ...stepGridYCoordinates.map((yMeters) => + horizontalSegment(gridExtent.minXMeters, yMeters, gridExtent.maxXMeters), + ), + ].join(" "); + + const fiveYardXCoordinates = template.allFiveYardLines.map( + (line) => line.coordinateMeters, + ); + const fiveYardYCoordinates = coordinatesAtInterval( + fieldExtent.minYMeters, + fieldExtent.maxYMeters, + FIVE_YARD_GRID_SPACING_METERS, + ); + const fiveYardGridPath = [ + ...fiveYardXCoordinates.map((xMeters) => + verticalSegment(xMeters, fieldExtent.minYMeters, fieldExtent.maxYMeters), + ), + ...fiveYardYCoordinates.map((yMeters) => + horizontalSegment( + fieldExtent.minXMeters, + yMeters, + fieldExtent.maxXMeters, + ), + ), + ].join(" "); + + const yardLinesPath = template.yardLines + .map((line) => + verticalSegment( + line.coordinateMeters, + fieldExtent.minYMeters, + fieldExtent.maxYMeters, + ), + ) + .join(" "); + + const hashYCoordinates = [ + template.frontHashLine.coordinateMeters, + template.backHashLine.coordinateMeters, + ] as const; + const hashMarks = [] as string[]; + const ticksPerRow = Math.max(0, Math.ceil(template.goalToGoalYards) - 1); + for (const yMeters of hashYCoordinates) { + for (let yard = 1; yard < template.goalToGoalYards; yard += 1) { + const xMeters = fieldExtent.minXMeters + yard * HASH_MARK_SPACING_METERS; + hashMarks.push( + verticalSegment( + xMeters, + yMeters - HASH_MARK_LENGTH_METERS / 2, + yMeters + HASH_MARK_LENGTH_METERS / 2, + ), + ); + } + } + const hashMarksPath = hashMarks.join(" "); + + const boundaryPath = rectanglePath(fieldExtent); + const extents = Object.freeze({ field: fieldExtent, grid: gridExtent }); + const counts: FieldPathCounts = Object.freeze({ + stepGrid: Object.freeze({ + spacingMeters: STANDARD_STEP_METERS, + verticalLineCount: stepGridXCoordinates.length, + horizontalLineCount: stepGridYCoordinates.length, + }), + fiveYardGrid: Object.freeze({ + spacingMeters: FIVE_YARD_GRID_SPACING_METERS, + verticalSubdivisionCount: fiveYardXCoordinates.length, + horizontalSubdivisionCount: fiveYardYCoordinates.length, + segmentCount: fiveYardXCoordinates.length + fiveYardYCoordinates.length, + clippedToField: true, + }), + yardLines: Object.freeze({ lineCount: template.yardLines.length }), + hashMarks: Object.freeze({ + spacingMeters: HASH_MARK_SPACING_METERS, + tickLengthMeters: HASH_MARK_LENGTH_METERS, + rowCount: 2, + ticksPerRow, + tickCount: hashMarks.length, + }), + boundary: Object.freeze({ segmentCount: 1 }), + }); + + const paths: FieldPaths = Object.freeze({ + stepGridPath, + fiveYardGridPath, + yardLinesPath, + hashMarksPath, + boundaryPath, + fieldExtent, + gridExtent, + stepGridSpacingMeters: STANDARD_STEP_METERS, + extents, + counts, + stepGrid: stepGridPath, + fiveYardGrid: fiveYardGridPath, + yardLines: yardLinesPath, + hashMarks: hashMarksPath, + boundary: boundaryPath, + }); + PATH_CACHE.set(template, paths); + return paths; +} + +/** Alias for callers that describe the operation as building geometry. */ +export const buildFieldPaths = createFieldPaths; + +function freezeExtent(extent: FieldPathExtent): FieldPathExtent { + return Object.freeze(extent); +} + +function coordinatesAtInterval( + minimum: number, + maximum: number, + interval: number, +): readonly number[] { + const coordinates: number[] = []; + const intervalCount = Math.floor( + (maximum - minimum) / interval + COORDINATE_EPSILON, + ); + for (let index = 0; index <= intervalCount; index += 1) { + coordinates.push(minimum + index * interval); + } + + return Object.freeze(coordinates); +} + +function verticalSegment( + xMeters: number, + minYMeters: number, + maxYMeters: number, +): string { + return `M ${formatCoordinate(xMeters)} ${formatCoordinate( + minYMeters, + )} L ${formatCoordinate(xMeters)} ${formatCoordinate(maxYMeters)}`; +} + +function horizontalSegment( + minXMeters: number, + yMeters: number, + maxXMeters: number, +): string { + return `M ${formatCoordinate(minXMeters)} ${formatCoordinate( + yMeters, + )} L ${formatCoordinate(maxXMeters)} ${formatCoordinate(yMeters)}`; +} + +function rectanglePath(extent: FieldPathExtent): string { + return `M ${formatCoordinate(extent.minXMeters)} ${formatCoordinate( + extent.minYMeters, + )} L ${formatCoordinate(extent.maxXMeters)} ${formatCoordinate( + extent.minYMeters, + )} L ${formatCoordinate(extent.maxXMeters)} ${formatCoordinate( + extent.maxYMeters, + )} L ${formatCoordinate(extent.minXMeters)} ${formatCoordinate( + extent.maxYMeters, + )} Z`; +} + +function formatCoordinate(value: number): string { + const rounded = + Math.round(value * PATH_NUMBER_PRECISION) / PATH_NUMBER_PRECISION; + return String(Object.is(rounded, -0) ? 0 : rounded); +} diff --git a/packages/mobile/src/field/render/field-canvas.tsx b/packages/mobile/src/field/render/field-canvas.tsx new file mode 100644 index 00000000..c12f7b23 --- /dev/null +++ b/packages/mobile/src/field/render/field-canvas.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import { + View, + type LayoutChangeEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { Canvas, Fill } from "@shopify/react-native-skia"; +import { GestureDetector } from "react-native-gesture-handler"; +import { useSharedValue } from "react-native-reanimated"; + +import { setFieldCamera } from "../camera/field-camera-math"; +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + type StandardHighSchoolFieldTemplate, +} from "../template"; +import { + getFieldCameraBounds, + getFieldGridBounds, + getInitialFieldViewport, +} from "../camera/field-camera-policy"; +import type { + FieldCamera, + FieldViewport, + FieldViewportSize, +} from "../camera/field-camera-types"; +import { useFieldGestures } from "../camera/use-field-gestures"; +import { createFieldPaths } from "./create-field-paths"; +import { FieldScene } from "./field-scene"; +import { + DEFAULT_FIELD_RENDER_PALETTE, + type FieldRenderPalette, +} from "./field-render-tokens"; + +export interface FieldCanvasProps { + readonly template?: StandardHighSchoolFieldTemplate; + readonly camera?: FieldCamera; + readonly defaultViewport?: FieldViewport; + readonly onViewportChange?: (viewport: FieldViewport) => void; + readonly palette?: FieldRenderPalette; + readonly style?: StyleProp; + readonly testID?: string; +} + +export function FieldCanvas({ + template = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + camera: externalCamera, + defaultViewport, + onViewportChange, + palette = DEFAULT_FIELD_RENDER_PALETTE, + style, + testID = "field-canvas", +}: FieldCanvasProps) { + const midpoint = { + xMeters: (template.bounds.minXMeters + template.bounds.maxXMeters) / 2, + yMeters: (template.bounds.minYMeters + template.bounds.maxYMeters) / 2, + }; + const centerXMeters = useSharedValue( + defaultViewport?.centerXMeters ?? midpoint.xMeters, + ); + const centerYMeters = useSharedValue( + defaultViewport?.centerYMeters ?? midpoint.yMeters, + ); + const metersPerPixel = useSharedValue( + defaultViewport?.metersPerPixel ?? 0.12, + ); + const internalCamera = React.useMemo( + () => ({ centerXMeters, centerYMeters, metersPerPixel }), + [centerXMeters, centerYMeters, metersPerPixel], + ); + const camera = externalCamera ?? internalCamera; + const canvasSize = useSharedValue({ width: 0, height: 0 }); + const initialized = React.useRef(Boolean(externalCamera || defaultViewport)); + const paths = React.useMemo(() => createFieldPaths(template), [template]); + const cameraBounds = React.useMemo( + () => getFieldCameraBounds(template), + [template], + ); + const gridBounds = React.useMemo( + () => getFieldGridBounds(template), + [template], + ); + const { gesture } = useFieldGestures({ + camera, + canvasSize, + cameraBounds, + gridBounds, + onViewportChange, + testID, + }); + + const onLayout = React.useCallback( + (event: LayoutChangeEvent) => { + if (initialized.current) return; + const size = event.nativeEvent.layout; + if (size.width <= 0 || size.height <= 0) return; + const initial = getInitialFieldViewport(size, template); + setFieldCamera(camera, initial); + initialized.current = true; + onViewportChange?.(initial); + }, + [camera, onViewportChange, template], + ); + + return ( + + + + + + + + + ); +} diff --git a/packages/mobile/src/field/render/field-render-tokens.ts b/packages/mobile/src/field/render/field-render-tokens.ts new file mode 100644 index 00000000..0f5b6f12 --- /dev/null +++ b/packages/mobile/src/field/render/field-render-tokens.ts @@ -0,0 +1,29 @@ +export const FIELD_FIVE_YARD_GRID_COLOR = "#6FA0E1"; + +export interface FieldRenderPalette { + readonly canvasBackground: string; + readonly stepGrid: string; + readonly fieldBackground: string; + readonly fiveYardGrid: string; + readonly fieldLines: string; + readonly fieldNumbers: string; + readonly livePosition: string; + readonly target: string; + readonly guidance: string; + readonly anchor: string; + readonly anchorRange: string; +} + +export const DEFAULT_FIELD_RENDER_PALETTE: FieldRenderPalette = Object.freeze({ + canvasBackground: "#E7EAF0", + stepGrid: "rgba(76, 93, 120, 0.22)", + fieldBackground: "rgba(247, 249, 252, 0.90)", + fiveYardGrid: FIELD_FIVE_YARD_GRID_COLOR, + fieldLines: "#5D6470", + fieldNumbers: "#69717D", + livePosition: "#3C6EC8", + target: "#D29B22", + guidance: "rgba(60, 110, 200, 0.74)", + anchor: "#7B5CC7", + anchorRange: "rgba(123, 92, 199, 0.14)", +}); diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx new file mode 100644 index 00000000..52db769b --- /dev/null +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Group } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { StandardHighSchoolFieldTemplate } from "../template"; +import type { + FieldCamera, + FieldViewportSize, +} from "../camera/field-camera-types"; +import type { FieldPaths } from "./create-field-paths"; +import { FieldStaticLayer } from "./field-static-layer"; +import type { FieldRenderPalette } from "./field-render-tokens"; + +interface FieldSceneProps { + readonly camera: FieldCamera; + readonly canvasSize: SharedValue; + readonly template: StandardHighSchoolFieldTemplate; + readonly paths: FieldPaths; + readonly palette: FieldRenderPalette; + readonly children?: React.ReactNode; +} + +export function FieldScene({ + camera, + canvasSize, + template, + paths, + palette, + children, +}: FieldSceneProps) { + const cameraTransform = useDerivedValue(() => [ + { translateX: canvasSize.value.width / 2 }, + { translateY: canvasSize.value.height / 2 }, + { scaleX: 1 / camera.metersPerPixel.value }, + { scaleY: -1 / camera.metersPerPixel.value }, + { translateX: -camera.centerXMeters.value }, + { translateY: -camera.centerYMeters.value }, + ]); + + return ( + + + {children} + + ); +} diff --git a/packages/mobile/src/field/render/field-static-layer.tsx b/packages/mobile/src/field/render/field-static-layer.tsx new file mode 100644 index 00000000..a31b0ca0 --- /dev/null +++ b/packages/mobile/src/field/render/field-static-layer.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { Group, matchFont, Path, Rect, Text } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { StandardHighSchoolFieldTemplate } from "../template"; +import type { FieldPaths } from "./create-field-paths"; +import type { FieldRenderPalette } from "./field-render-tokens"; + +interface FieldStaticLayerProps { + readonly template: StandardHighSchoolFieldTemplate; + readonly paths: FieldPaths; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; +} + +export const FieldStaticLayer = React.memo(function FieldStaticLayer({ + template, + paths, + metersPerPixel, + palette, +}: FieldStaticLayerProps) { + const stepGridStroke = useDerivedValue(() => metersPerPixel.value * 0.7); + const fiveYardStroke = useDerivedValue(() => metersPerPixel.value * 1.1); + const fieldLineStroke = useDerivedValue(() => metersPerPixel.value * 1.4); + const boundaryStroke = useDerivedValue(() => metersPerPixel.value * 2); + const numberFont = React.useMemo( + () => + matchFont({ + fontFamily: "Montserrat", + fontSize: template.dimensions.yardNumberHeightMeters, + fontWeight: "600", + }), + [template], + ); + const fieldClip = { + x: template.bounds.minXMeters, + y: template.bounds.minYMeters, + width: template.goalToGoalMeters, + height: template.widthMeters, + }; + + return ( + <> + + + + + + + + + {template.yardNumbers.map((number) => { + const width = numberFont.measureText(number.label).width; + return ( + + + + ); + })} + + ); +}); diff --git a/packages/mobile/src/field/render/index.ts b/packages/mobile/src/field/render/index.ts new file mode 100644 index 00000000..8d28c231 --- /dev/null +++ b/packages/mobile/src/field/render/index.ts @@ -0,0 +1,3 @@ +export * from "./create-field-paths"; +export * from "./field-render-tokens"; +export * from "./field-canvas"; diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 65710a1a..7dd35cd0 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -32,6 +32,11 @@ export { export * from "./field/template"; export * from "./field/marching"; export * from "./field/guidance"; +export * from "./field/camera/field-camera-types"; +export * from "./field/camera/field-camera-math"; +export * from "./field/camera/field-camera-policy"; +export * from "./field/render/create-field-paths"; +export * from "./field/render/field-render-tokens"; export * from "./drill"; export * from "./settings"; export * from "./storage"; diff --git a/packages/mobile/src/pans-manager/pans-network-grid-camera.ts b/packages/mobile/src/pans-manager/pans-network-grid-camera.ts index ce2374a3..2950c484 100644 --- a/packages/mobile/src/pans-manager/pans-network-grid-camera.ts +++ b/packages/mobile/src/pans-manager/pans-network-grid-camera.ts @@ -4,6 +4,11 @@ import type { GridViewport, } from "./pans-network-grid-math"; import type { PansGridCameraSharedValues } from "./pans-network-grid-types"; +import { + clampFieldCameraAxis, + fieldCenterForStationaryWorldPoint, + fieldScreenToWorld, +} from "../field/camera/field-camera-math"; export function panCameraCenter( startCenter: GridPoint, @@ -25,10 +30,15 @@ export function screenPointToWorld( metersPerPixel: number, ): GridPoint { "worklet"; - return { - xMeters: center.xMeters + (point.x - size.width / 2) * metersPerPixel, - yMeters: center.yMeters - (point.y - size.height / 2) * metersPerPixel, - }; + return fieldScreenToWorld( + point, + { + centerXMeters: center.xMeters, + centerYMeters: center.yMeters, + metersPerPixel, + }, + size, + ); } export function centerForStationaryWorldPoint( @@ -38,12 +48,12 @@ export function centerForStationaryWorldPoint( metersPerPixel: number, ): GridPoint { "worklet"; - return { - xMeters: - worldPoint.xMeters - (screenPoint.x - size.width / 2) * metersPerPixel, - yMeters: - worldPoint.yMeters + (screenPoint.y - size.height / 2) * metersPerPixel, - }; + return fieldCenterForStationaryWorldPoint( + worldPoint, + screenPoint, + size, + metersPerPixel, + ); } export function setGridCamera( @@ -71,8 +81,5 @@ export function clampCameraAxis( minimum >= maximum ) return center; - const minimumCenter = minimum + halfVisibleSpan; - const maximumCenter = maximum - halfVisibleSpan; - if (minimumCenter > maximumCenter) return (minimum + maximum) / 2; - return Math.min(maximumCenter, Math.max(minimumCenter, center)); + return clampFieldCameraAxis(center, minimum, maximum, halfVisibleSpan); } From 4efb2513a4074f165b0c78e9bfc2d46ec3ac7160 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 23:09:01 -0500 Subject: [PATCH 013/101] feat(field): implement responsive field screen --- apps/mobile/app/_layout.tsx | 17 ++- .../__tests__/field-overlay-layout.test.ts | 37 ++++++ .../features/field/field-overlay-layout.tsx | 124 ++++++++++++++++++ .../src/features/field/field-screen.tsx | 44 ++++++- .../field/use-field-screen-controller.ts | 33 +++++ 5 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts create mode 100644 apps/mobile/src/features/field/field-overlay-layout.tsx create mode 100644 apps/mobile/src/features/field/use-field-screen-controller.ts diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 248f054c..ae20da2e 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -3,6 +3,7 @@ import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GluestackUIProvider } from "@eight2five/ui/components/gluestack-ui-provider"; import { useEight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; @@ -30,13 +31,15 @@ export default function MobileRootLayout() { if (!fontsLoaded && !fontError) return null; return ( - - - - - - - + + + + + + + + + ); } diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts new file mode 100644 index 00000000..ab4b56b2 --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts @@ -0,0 +1,37 @@ +import { getFieldOverlayMetrics } from "../field-overlay-layout"; + +const insets = { top: 24, right: 10, bottom: 20, left: 10 }; + +describe("Field overlay layout", () => { + test("places a safe-area-aware HUD and right-side dial in landscape", () => { + const layout = getFieldOverlayMetrics({ + width: 844, + height: 390, + landscape: true, + insets, + }); + + expect(layout.dialDiameter).toBeGreaterThanOrEqual(148); + expect(layout.dialDiameter).toBeLessThanOrEqual(172); + expect(layout.hudStyle.top).toBe(40); + expect(layout.hudStyle.left).toBe(26); + expect(layout.hudStyle.width).toBeLessThanOrEqual(844 * 0.72); + expect(layout.dialStyle.right).toBe(26); + }); + + test("centers the dial above the bottom inset and expands the HUD in portrait", () => { + const layout = getFieldOverlayMetrics({ + width: 390, + height: 844, + landscape: false, + insets, + }); + + expect(layout.dialDiameter).toBeGreaterThanOrEqual(140); + expect(layout.dialDiameter).toBeLessThanOrEqual(156); + expect(layout.hudStyle.left).toBe(22); + expect(layout.hudStyle.right).toBe(22); + expect(layout.dialStyle.bottom).toBe(32); + expect(layout.dialStyle.left).toBe((390 - layout.dialDiameter) / 2); + }); +}); diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx new file mode 100644 index 00000000..9c5afaec --- /dev/null +++ b/apps/mobile/src/features/field/field-overlay-layout.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { View, type ViewStyle } from "react-native"; +import { + useSafeAreaInsets, + type EdgeInsets, +} from "react-native-safe-area-context"; + +export interface FieldOverlayMetrics { + readonly outerPadding: number; + readonly hudStyle: ViewStyle; + readonly dialStyle: ViewStyle; + readonly dialDiameter: number; +} + +export function getFieldOverlayMetrics({ + width, + height, + landscape, + insets, +}: { + readonly width: number; + readonly height: number; + readonly landscape: boolean; + readonly insets: EdgeInsets; +}): FieldOverlayMetrics { + const outerPadding = landscape ? 16 : 12; + const availableWidth = Math.max(0, width - insets.left - insets.right); + const dialDiameter = landscape + ? Math.min(172, Math.max(148, height * 0.42)) + : Math.min(156, Math.max(140, width * 0.38)); + + if (landscape) { + const right = insets.right + outerPadding; + return { + outerPadding, + dialDiameter, + hudStyle: { + position: "absolute", + top: insets.top + outerPadding, + left: insets.left + outerPadding, + width: Math.min(availableWidth * 0.72, 720), + maxHeight: 136, + }, + dialStyle: { + position: "absolute", + right, + top: Math.max( + insets.top + outerPadding, + (height - dialDiameter + insets.top - insets.bottom) / 2, + ), + width: dialDiameter, + height: dialDiameter, + }, + }; + } + + return { + outerPadding, + dialDiameter, + hudStyle: { + position: "absolute", + top: insets.top + outerPadding, + left: insets.left + outerPadding, + right: insets.right + outerPadding, + maxHeight: 196, + }, + dialStyle: { + position: "absolute", + alignSelf: "center", + left: (width - dialDiameter) / 2, + bottom: insets.bottom + outerPadding, + width: dialDiameter, + height: dialDiameter, + }, + }; +} + +interface FieldOverlayLayoutProps { + readonly width: number; + readonly height: number; + readonly landscape: boolean; + readonly field: React.ReactNode; + readonly hud?: React.ReactNode; + readonly dial?: React.ReactNode; +} + +export function FieldOverlayLayout({ + width, + height, + landscape, + field, + hud, + dial, +}: FieldOverlayLayoutProps) { + const insets = useSafeAreaInsets(); + const metrics = getFieldOverlayMetrics({ width, height, landscape, insets }); + + return ( + + {field} + {hud ? ( + + {hud} + + ) : null} + {dial ? ( + + {dial} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index c0ba50bf..f9155572 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -1,13 +1,45 @@ -import { PlaceholderScreen } from "../placeholder-screen"; -import { useFieldOrientation } from "../../navigation/use-field-orientation"; +import React from "react"; +import { + FIELD_FIVE_YARD_GRID_COLOR, + FieldCanvas, +} from "@eight2five/mobile/field/render"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { FieldOverlayLayout } from "./field-overlay-layout"; +import { useFieldScreenController } from "./use-field-screen-controller"; export function FieldScreen() { - useFieldOrientation(); + const theme = useEight2FiveTheme(); + const controller = useFieldScreenController(); + const palette = React.useMemo( + () => ({ + canvasBackground: theme.background, + stepGrid: theme.textSubtle, + fieldBackground: theme.surfaceRaised, + fiveYardGrid: FIELD_FIVE_YARD_GRID_COLOR, + fieldLines: theme.textMuted, + fieldNumbers: theme.textMuted, + livePosition: theme.accent, + target: "#D29B22", + guidance: theme.accent, + anchor: theme.warning, + anchorRange: theme.warningSoft, + }), + [theme], + ); return ( - + } /> ); } diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts new file mode 100644 index 00000000..59e46972 --- /dev/null +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -0,0 +1,33 @@ +import React from "react"; +import { useWindowDimensions } from "react-native"; +import type { FieldViewport } from "@eight2five/mobile/field"; + +import { useFieldOrientation } from "../../navigation/use-field-orientation"; + +let committedFieldViewport: FieldViewport | undefined; + +/** + * Owns viewport commits outside the renderer. The module-level session value is + * deliberate: native-tab presentation changes may remount the route, but they + * must not reset the performer's field center or zoom. + */ +export function useFieldScreenController() { + const orientation = useFieldOrientation(); + const { width, height } = useWindowDimensions(); + const [initialViewport] = React.useState(() => committedFieldViewport); + const commitViewport = React.useCallback((viewport: FieldViewport) => { + committedFieldViewport = viewport; + }, []); + + return { + width, + height, + landscape: orientation.landscape, + defaultViewport: initialViewport, + commitViewport, + } as const; +} + +export function resetFieldViewportSessionForTests(): void { + committedFieldViewport = undefined; +} From ec3127365af4b37c76343e259adec43fbd41f753 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 23:18:00 -0500 Subject: [PATCH 014/101] feat(field): add live and drill coordinate HUD --- apps/mobile/package.json | 1 + .../__tests__/coordinate-panel-state.test.ts | 111 ++++++++++++++++ .../__tests__/drill-menu.test.ts | 26 ++++ .../coordinate-panel/connection-indicator.tsx | 62 +++++++++ .../coordinate-panel-state.ts | 118 ++++++++++++++++++ .../coordinate-panel/coordinate-panel.tsx | 110 ++++++++++++++++ .../coordinate-panel/drill-coordinate-row.tsx | 114 +++++++++++++++++ .../coordinate-panel/drill-menu-state.ts | 25 ++++ .../field/coordinate-panel/drill-menu.tsx | 60 +++++++++ .../coordinate-panel/live-coordinate-row.tsx | 41 ++++++ .../transition-metric-cell.tsx | 64 ++++++++++ .../src/features/field/field-screen.tsx | 35 +++++- .../field/use-field-screen-controller.ts | 97 ++++++++++++++ package-lock.json | 1 + packages/mobile/src/field/index.ts | 1 + packages/mobile/src/field/live-position.ts | 34 +++++ packages/mobile/src/index.ts | 1 + 17 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts create mode 100644 apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts create mode 100644 apps/mobile/src/features/field/coordinate-panel/connection-indicator.tsx create mode 100644 apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts create mode 100644 apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx create mode 100644 apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx create mode 100644 apps/mobile/src/features/field/coordinate-panel/drill-menu-state.ts create mode 100644 apps/mobile/src/features/field/coordinate-panel/drill-menu.tsx create mode 100644 apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx create mode 100644 apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx create mode 100644 packages/mobile/src/field/live-position.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c7a69be1..f09176d2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,6 +20,7 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", + "@expo/ui": "~57.0.8", "expo": "~57.0.9", "expo-dev-client": "~57.0.10", "expo-router": "~57.0.9", diff --git a/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts b/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts new file mode 100644 index 00000000..8c98e513 --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts @@ -0,0 +1,111 @@ +import type { DrillPage } from "@eight2five/mobile/drill"; + +import { + areCoordinatePanelControlsDisabled, + getDrillCoordinatePresentation, + getLiveCoordinatePresentation, +} from "../coordinate-panel-state"; + +const first: DrillPage = { + id: "p1", + drillId: "d1", + ordinal: 0, + label: "1", + countsFromPrevious: 0, + position: { xMeters: 36.576, yMeters: 4.064 }, +}; +const second: DrillPage = { + id: "p2", + drillId: "d1", + ordinal: 1, + label: "2", + countsFromPrevious: 8, + position: { xMeters: 41.148, yMeters: 4.064 }, +}; + +describe("coordinate panel state", () => { + test("presents waiting, live, and stale states", () => { + expect( + getLiveCoordinatePresentation({ + connectionState: "idle", + isStale: false, + }), + ).toMatchObject({ + primary: "Waiting for live position", + secondary: "Connect a PANS tag to begin", + muted: true, + }); + expect( + getLiveCoordinatePresentation({ + connectionState: "connected", + position: second.position, + isStale: false, + }).primary, + ).toContain("Side 1"); + expect( + getLiveCoordinatePresentation({ + connectionState: "disconnected", + position: second.position, + isStale: true, + }), + ).toMatchObject({ statusLabel: "Last known position", muted: true }); + }); + + test("keeps the empty drill model stable and terminology-aware", () => { + expect( + getDrillCoordinatePresentation({ + terminology: "sets", + metricMode: "step-size", + }), + ).toEqual({ + term: "Set", + page: "–", + counts: "–", + metricLabel: "Step Size", + metric: "–", + coordinate: null, + emptyMessage: "No drill page selected", + }); + }); + + test("toggles between step-size and crossing-count metrics", () => { + const stepSize = getDrillCoordinatePresentation({ + page: second, + previousPage: first, + terminology: "pages", + metricMode: "step-size", + }); + const crossingCounts = getDrillCoordinatePresentation({ + page: second, + previousPage: first, + terminology: "pages", + metricMode: "crossing-counts", + }); + + expect(stepSize).toMatchObject({ + term: "Page", + page: "2", + counts: "8", + metricLabel: "Step Size", + metric: "8 to 5", + }); + expect(crossingCounts.metricLabel).toBe("xCounts"); + }); + + test("disables controls until storage and drill data are ready", () => { + expect( + areCoordinatePanelControlsDisabled({ + settingsReady: false, + loadingDrills: false, + selectionBusy: false, + }), + ).toBe(true); + expect( + areCoordinatePanelControlsDisabled({ + settingsReady: true, + loadingDrills: false, + selectionBusy: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts b/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts new file mode 100644 index 00000000..24da1859 --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts @@ -0,0 +1,26 @@ +import type { Drill } from "@eight2five/mobile/drill"; + +import { createDrillMenuActions } from "../drill-menu-state"; + +const drills: Drill[] = [ + { id: "one", name: "Opener 2026", createdAt: 1, updatedAt: 1 }, + { id: "two", name: "Closer", createdAt: 2, updatedAt: 2 }, +]; + +describe("active drill menu", () => { + test("marks the active drill and preserves the no-drill action", () => { + expect(createDrillMenuActions(drills, "one")).toMatchObject([ + { id: "__no-drill__", state: "off" }, + { id: "one", state: "on" }, + { id: "two", state: "off" }, + ]); + }); + + test("disables every native action while storage is busy", () => { + expect( + createDrillMenuActions(drills, null, true).every( + (action) => action.attributes?.disabled, + ), + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/field/coordinate-panel/connection-indicator.tsx b/apps/mobile/src/features/field/coordinate-panel/connection-indicator.tsx new file mode 100644 index 00000000..b0552714 --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/connection-indicator.tsx @@ -0,0 +1,62 @@ +import { Icon } from "@eight2five/ui/components/icon"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { + BluetoothConnected, + BluetoothOff, + LoaderCircle, + RefreshCw, + TriangleAlert, +} from "lucide-react-native"; +import type { FieldConnectionState } from "@eight2five/mobile/field"; + +const CONNECTION_PRESENTATION = { + idle: { icon: BluetoothOff, label: "PANS tag idle", color: "#AAB0BA" }, + connecting: { + icon: LoaderCircle, + label: "Connecting to PANS tag", + color: "#6FA0E1", + }, + connected: { + icon: BluetoothConnected, + label: "PANS tag connected", + color: "#68C36D", + }, + reconnecting: { + icon: RefreshCw, + label: "Reconnecting to PANS tag", + color: "#E2B84F", + }, + disconnected: { + icon: BluetoothOff, + label: "PANS tag disconnected", + color: "#AAB0BA", + }, + error: { + icon: TriangleAlert, + label: "PANS tag connection error", + color: "#E16B6B", + }, +} as const; + +export function ConnectionIndicator({ + state, +}: { + state: FieldConnectionState; +}) { + const presentation = CONNECTION_PRESENTATION[state]; + return ( + + + + ); +} diff --git a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts new file mode 100644 index 00000000..c8ae92bc --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts @@ -0,0 +1,118 @@ +import { + fieldPointToMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, + type FieldLivePositionState, +} from "@eight2five/mobile/field"; +import { + getDrillTerms, + type DrillPage, + type DrillTerminology, +} from "@eight2five/mobile/drill"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import { getTransitionPresentation } from "../../drill/transition-presentation"; + +export interface CoordinateLines { + readonly side: string; + readonly frontBack: string; +} + +export interface LiveCoordinatePresentation { + readonly statusLabel?: string; + readonly primary: string; + readonly secondary: string; + readonly muted: boolean; +} + +export interface DrillCoordinatePresentation { + readonly term: "Page" | "Set"; + readonly page: string; + readonly counts: string; + readonly metricLabel: "Step Size" | "xCounts"; + readonly metric: string; + readonly coordinate: CoordinateLines | null; + readonly emptyMessage?: string; +} + +export function areCoordinatePanelControlsDisabled({ + settingsReady, + loadingDrills, + selectionBusy, +}: { + readonly settingsReady: boolean; + readonly loadingDrills: boolean; + readonly selectionBusy: boolean; +}): boolean { + return !settingsReady || loadingDrills || selectionBusy; +} + +export function formatCoordinateLines( + position: DrillPage["position"], +): CoordinateLines { + const coordinate = fieldPointToMarchingCoordinate(position); + return { + side: formatMarchingSide(coordinate.side), + frontBack: formatMarchingFrontBack(coordinate.frontBack), + }; +} + +export function getLiveCoordinatePresentation( + live: FieldLivePositionState, +): LiveCoordinatePresentation { + if (!live.position) { + return { + primary: "Waiting for live position", + secondary: + live.connectionState === "error" && live.errorMessage + ? live.errorMessage + : "Connect a PANS tag to begin", + muted: true, + }; + } + const coordinate = formatCoordinateLines(live.position); + return { + ...(live.isStale ? { statusLabel: "Last known position" } : {}), + primary: coordinate.side, + secondary: coordinate.frontBack, + muted: live.isStale, + }; +} + +export function getDrillCoordinatePresentation({ + page, + previousPage, + terminology, + metricMode, +}: { + readonly page?: DrillPage; + readonly previousPage?: DrillPage; + readonly terminology: DrillTerminology; + readonly metricMode: TransitionMetricMode; +}): DrillCoordinatePresentation { + const term = getDrillTerms(terminology).singular; + const metricLabel = metricMode === "step-size" ? "Step Size" : "xCounts"; + if (!page) { + return { + term, + page: "–", + counts: "–", + metricLabel, + metric: "–", + coordinate: null, + emptyMessage: "No drill page selected", + }; + } + const transition = getTransitionPresentation(previousPage, page); + return { + term, + page: page.label || String(page.ordinal + 1), + counts: previousPage ? String(page.countsFromPrevious) : "–", + metricLabel, + metric: + metricMode === "step-size" + ? transition.stepSize + : transition.crossingCounts, + coordinate: formatCoordinateLines(page.position), + }; +} diff --git a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx new file mode 100644 index 00000000..2e40a24f --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx @@ -0,0 +1,110 @@ +import { Box } from "@eight2five/ui/components/box"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import type { FieldLivePositionState } from "@eight2five/mobile/field"; +import type { + Drill, + DrillPage, + DrillTerminology, +} from "@eight2five/mobile/drill"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import { ConnectionIndicator } from "./connection-indicator"; +import { DrillCoordinateRow } from "./drill-coordinate-row"; +import { DrillMenu } from "./drill-menu"; +import { LiveCoordinateRow } from "./live-coordinate-row"; + +export interface CoordinatePanelProps { + readonly landscape: boolean; + readonly live: FieldLivePositionState; + readonly drillFeaturesEnabled: boolean; + readonly drills: readonly Drill[]; + readonly activeDrill?: Drill; + readonly selectedPage?: DrillPage; + readonly previousPage?: DrillPage; + readonly terminology: DrillTerminology; + readonly metricMode: TransitionMetricMode; + readonly controlsDisabled: boolean; + readonly error?: Error; + readonly onSelectDrill: (drillId: string | null) => void; + readonly onToggleMetric: () => void; +} + +export function CoordinatePanel({ + landscape, + live, + drillFeaturesEnabled, + drills, + activeDrill, + selectedPage, + previousPage, + terminology, + metricMode, + controlsDisabled, + error, + onSelectDrill, + onToggleMetric, +}: CoordinatePanelProps) { + const height = drillFeaturesEnabled ? (landscape ? 132 : 188) : 76; + return ( + + + + + {drillFeaturesEnabled ? ( + + ) : null} + + {drillFeaturesEnabled ? ( + <> + + + + ) : null} + {error ? ( + + {error.message} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx new file mode 100644 index 00000000..d4ed1a41 --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx @@ -0,0 +1,114 @@ +import { HStack } from "@eight2five/ui/components/hstack"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import type { DrillPage, DrillTerminology } from "@eight2five/mobile/drill"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import { getDrillCoordinatePresentation } from "./coordinate-panel-state"; +import { TransitionMetricCell } from "./transition-metric-cell"; + +function MetadataCell({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function DrillCoordinate({ + coordinate, + emptyMessage, +}: Pick< + ReturnType, + "coordinate" | "emptyMessage" +>) { + return ( + + + {coordinate?.side ?? emptyMessage} + + {coordinate ? ( + + {coordinate.frontBack} + + ) : null} + + ); +} + +export function DrillCoordinateRow({ + page, + previousPage, + terminology, + metricMode, + landscape, + metricToggleDisabled, + onToggleMetric, +}: { + readonly page?: DrillPage; + readonly previousPage?: DrillPage; + readonly terminology: DrillTerminology; + readonly metricMode: TransitionMetricMode; + readonly landscape: boolean; + readonly metricToggleDisabled: boolean; + readonly onToggleMetric: () => void; +}) { + const presentation = getDrillCoordinatePresentation({ + page, + previousPage, + terminology, + metricMode, + }); + const metadata = ( + + + + + + ); + + return landscape ? ( + + {metadata} + + + ) : ( + + {metadata} + + + ); +} diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-menu-state.ts b/apps/mobile/src/features/field/coordinate-panel/drill-menu-state.ts new file mode 100644 index 00000000..40aa865a --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/drill-menu-state.ts @@ -0,0 +1,25 @@ +import type { MenuAction } from "@expo/ui/community/menu"; +import type { Drill } from "@eight2five/mobile/drill"; + +export const NO_DRILL_ACTION_ID = "__no-drill__"; + +export function createDrillMenuActions( + drills: readonly Drill[], + activeDrillId: string | null, + disabled = false, +): MenuAction[] { + return [ + { + id: NO_DRILL_ACTION_ID, + title: "No drill selected", + state: activeDrillId === null ? "on" : "off", + attributes: { disabled }, + }, + ...drills.map((drill) => ({ + id: drill.id, + title: drill.name, + state: activeDrillId === drill.id ? ("on" as const) : ("off" as const), + attributes: { disabled }, + })), + ]; +} diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-menu.tsx b/apps/mobile/src/features/field/coordinate-panel/drill-menu.tsx new file mode 100644 index 00000000..c0a1a56d --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/drill-menu.tsx @@ -0,0 +1,60 @@ +import { MenuView } from "@expo/ui/community/menu"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { ChevronDown, Flag } from "lucide-react-native"; +import type { Drill } from "@eight2five/mobile/drill"; + +import { createDrillMenuActions, NO_DRILL_ACTION_ID } from "./drill-menu-state"; + +export function DrillMenu({ + drills, + activeDrill, + disabled, + onSelect, +}: { + readonly drills: readonly Drill[]; + readonly activeDrill?: Drill; + readonly disabled: boolean; + readonly onSelect: (drillId: string | null) => void; +}) { + return ( + { + if (disabled) return; + onSelect( + nativeEvent.event === NO_DRILL_ACTION_ID ? null : nativeEvent.event, + ); + }} + testID="active-drill-menu" + > + + + {activeDrill ? ( + + ) : null} + + {activeDrill?.name ?? "No drill selected"} + + + + + + ); +} diff --git a/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx b/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx new file mode 100644 index 00000000..6b4456ad --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx @@ -0,0 +1,41 @@ +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import type { FieldLivePositionState } from "@eight2five/mobile/field"; + +import { getLiveCoordinatePresentation } from "./coordinate-panel-state"; + +export function LiveCoordinateRow({ live }: { live: FieldLivePositionState }) { + const presentation = getLiveCoordinatePresentation(live); + const color = presentation.muted ? "rgba(255,255,255,0.58)" : "#FFFFFF"; + return ( + + {presentation.statusLabel ? ( + + {presentation.statusLabel} + + ) : null} + + {presentation.primary} + + + {presentation.secondary} + + + ); +} diff --git a/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx b/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx new file mode 100644 index 00000000..27997885 --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx @@ -0,0 +1,64 @@ +import { Box } from "@eight2five/ui/components/box"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; + +export function TransitionMetricCell({ + label, + value, + disabled, + onToggle, +}: { + readonly label: "Step Size" | "xCounts"; + readonly value: string; + readonly disabled: boolean; + readonly onToggle: () => void; +}) { + const stepSizeSelected = label === "Step Size"; + return ( + + + + {label} + + + {value} + + + + + + + + ); +} diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index f9155572..53b42508 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -1,4 +1,8 @@ import React from "react"; +import { + EMPTY_FIELD_LIVE_POSITION_STATE, + type FieldLivePositionInput, +} from "@eight2five/mobile/field"; import { FIELD_FIVE_YARD_GRID_COLOR, FieldCanvas, @@ -7,8 +11,14 @@ import { useEight2FiveTheme } from "@eight2five/ui/theme"; import { FieldOverlayLayout } from "./field-overlay-layout"; import { useFieldScreenController } from "./use-field-screen-controller"; +import { CoordinatePanel } from "./coordinate-panel/coordinate-panel"; +import { areCoordinatePanelControlsDisabled } from "./coordinate-panel/coordinate-panel-state"; -export function FieldScreen() { +export function FieldScreen({ + livePosition, +}: { + readonly livePosition?: FieldLivePositionInput; +}) { const theme = useEight2FiveTheme(); const controller = useFieldScreenController(); const palette = React.useMemo( @@ -40,6 +50,29 @@ export function FieldScreen() { palette={palette} /> } + hud={ + + void controller.selectActiveDrill(drillId) + } + onToggleMetric={() => void controller.toggleMetricMode()} + /> + } /> ); } diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts index 59e46972..e56474af 100644 --- a/apps/mobile/src/features/field/use-field-screen-controller.ts +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -1,8 +1,14 @@ import React from "react"; +import { useFocusEffect } from "expo-router"; import { useWindowDimensions } from "react-native"; import type { FieldViewport } from "@eight2five/mobile/field"; +import type { Drill, DrillPage } from "@eight2five/mobile/drill"; import { useFieldOrientation } from "../../navigation/use-field-orientation"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; let committedFieldViewport: FieldViewport | undefined; @@ -14,17 +20,108 @@ let committedFieldViewport: FieldViewport | undefined; export function useFieldScreenController() { const orientation = useFieldOrientation(); const { width, height } = useWindowDimensions(); + const snapshot = useAppSettingsSnapshot(); + const store = useAppSettingsStore(); const [initialViewport] = React.useState(() => committedFieldViewport); + const [drills, setDrills] = React.useState([]); + const [activeDrill, setActiveDrill] = React.useState(); + const [pages, setPages] = React.useState([]); + const [loadingDrills, setLoadingDrills] = React.useState(true); + const [fieldError, setFieldError] = React.useState(); + const [selectionBusy, setSelectionBusy] = React.useState(false); + const refreshGeneration = React.useRef(0); const commitViewport = React.useCallback((viewport: FieldViewport) => { committedFieldViewport = viewport; }, []); + const refreshDrills = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + const generation = ++refreshGeneration.current; + setLoadingDrills(true); + try { + const repository = store.getDrillRepository(); + const activeDrillId = snapshot.settings.activeDrillId; + const [nextDrills, nextActiveDrill, nextPages] = await Promise.all([ + repository.listDrills(), + activeDrillId ? repository.getDrill(activeDrillId) : undefined, + activeDrillId ? repository.listPages(activeDrillId) : [], + ]); + if (generation !== refreshGeneration.current) return; + setDrills(nextDrills); + setActiveDrill(nextActiveDrill); + setPages(nextPages); + setFieldError(undefined); + } catch (cause) { + if (generation !== refreshGeneration.current) return; + setFieldError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + if (generation === refreshGeneration.current) setLoadingDrills(false); + } + }, [snapshot.settings.activeDrillId, snapshot.status, store]); + + useFocusEffect( + React.useCallback(() => { + void refreshDrills(); + }, [refreshDrills]), + ); + + const selectActiveDrill = React.useCallback( + async (drillId: string | null) => { + if (selectionBusy || snapshot.status !== "ready") return; + setSelectionBusy(true); + setFieldError(undefined); + try { + await store.setActiveDrill(drillId); + } catch (cause) { + setFieldError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } finally { + setSelectionBusy(false); + } + }, + [selectionBusy, snapshot.status, store], + ); + + const toggleMetricMode = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + try { + await store.update({ + transitionMetricMode: + snapshot.settings.transitionMetricMode === "step-size" + ? "crossing-counts" + : "step-size", + }); + } catch (cause) { + setFieldError(cause instanceof Error ? cause : new Error(String(cause))); + } + }, [snapshot.settings.transitionMetricMode, snapshot.status, store]); + + const selectedIndex = pages.findIndex( + (page) => page.id === snapshot.settings.selectedDrillPageId, + ); + const selectedPage = selectedIndex >= 0 ? pages[selectedIndex] : undefined; + return { width, height, landscape: orientation.landscape, defaultViewport: initialViewport, commitViewport, + settingsStatus: snapshot.status, + settings: snapshot.settings, + drills, + activeDrill, + pages, + selectedIndex, + selectedPage, + previousPage: selectedIndex > 0 ? pages[selectedIndex - 1] : undefined, + loadingDrills, + selectionBusy, + error: fieldError ?? snapshot.error, + selectActiveDrill, + toggleMetricMode, + refreshDrills, } as const; } diff --git a/package-lock.json b/package-lock.json index fe65f91f..8f87c2f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", + "@expo/ui": "~57.0.8", "expo": "~57.0.9", "expo-dev-client": "~57.0.10", "expo-router": "~57.0.9", diff --git a/packages/mobile/src/field/index.ts b/packages/mobile/src/field/index.ts index 173360b5..1f4444a2 100644 --- a/packages/mobile/src/field/index.ts +++ b/packages/mobile/src/field/index.ts @@ -3,6 +3,7 @@ export * from "./units"; export * from "./template"; export * from "./marching"; export * from "./guidance"; +export * from "./live-position"; export * from "./camera/field-camera-types"; export * from "./camera/field-camera-math"; export * from "./camera/field-camera-policy"; diff --git a/packages/mobile/src/field/live-position.ts b/packages/mobile/src/field/live-position.ts new file mode 100644 index 00000000..25c30ef1 --- /dev/null +++ b/packages/mobile/src/field/live-position.ts @@ -0,0 +1,34 @@ +import type { SharedValue } from "react-native-reanimated"; + +import type { FieldPoint } from "./types"; + +export type FieldConnectionState = + | "idle" + | "connecting" + | "connected" + | "reconnecting" + | "disconnected" + | "error"; + +export interface FieldLivePositionState { + readonly connectionState: FieldConnectionState; + readonly position?: FieldPoint; + readonly receivedAt?: number; + readonly isStale: boolean; + readonly errorMessage?: string; +} + +/** + * Thread 4 can update positionValue on its streaming cadence while replacing + * state only for connection, stale, and human-readable HUD changes. + */ +export interface FieldLivePositionInput { + readonly state: FieldLivePositionState; + readonly positionValue?: SharedValue; +} + +export const EMPTY_FIELD_LIVE_POSITION_STATE: FieldLivePositionState = + Object.freeze({ + connectionState: "idle", + isStale: false, + }); diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 7dd35cd0..7930a76d 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -32,6 +32,7 @@ export { export * from "./field/template"; export * from "./field/marching"; export * from "./field/guidance"; +export * from "./field/live-position"; export * from "./field/camera/field-camera-types"; export * from "./field/camera/field-camera-math"; export * from "./field/camera/field-camera-policy"; From 59a263c6f137a1c0a74ef0cbe1c3b840e7f169e4 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 23:32:41 -0500 Subject: [PATCH 015/101] feat(field): add circular page selector --- .../features/field/field-overlay-layout.tsx | 4 +- .../src/features/field/field-screen.tsx | 19 +++ .../__tests__/page-dial-math.test.ts | 66 ++++++++++ .../field/page-dial/page-dial-canvas.tsx | 36 ++++++ .../field/page-dial/page-dial-controls.tsx | 118 ++++++++++++++++++ .../field/page-dial/page-dial-gesture.ts | 74 +++++++++++ .../field/page-dial/page-dial-layout.ts | 50 ++++++++ .../field/page-dial/page-dial-math.ts | 61 +++++++++ .../features/field/page-dial/page-dial.tsx | 84 +++++++++++++ .../field/use-field-screen-controller.ts | 39 +++++- packages/mobile/src/field/render/index.ts | 1 + .../src/field/render/page-dial-canvas.tsx | 98 +++++++++++++++ 12 files changed, 647 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts create mode 100644 apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx create mode 100644 apps/mobile/src/features/field/page-dial/page-dial-controls.tsx create mode 100644 apps/mobile/src/features/field/page-dial/page-dial-gesture.ts create mode 100644 apps/mobile/src/features/field/page-dial/page-dial-layout.ts create mode 100644 apps/mobile/src/features/field/page-dial/page-dial-math.ts create mode 100644 apps/mobile/src/features/field/page-dial/page-dial.tsx create mode 100644 packages/mobile/src/field/render/page-dial-canvas.tsx diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx index 9c5afaec..8a82cb5a 100644 --- a/apps/mobile/src/features/field/field-overlay-layout.tsx +++ b/apps/mobile/src/features/field/field-overlay-layout.tsx @@ -81,7 +81,7 @@ interface FieldOverlayLayoutProps { readonly landscape: boolean; readonly field: React.ReactNode; readonly hud?: React.ReactNode; - readonly dial?: React.ReactNode; + readonly dial?: (diameter: number) => React.ReactNode; } export function FieldOverlayLayout({ @@ -116,7 +116,7 @@ export function FieldOverlayLayout({ style={metrics.dialStyle} testID="field-dial-slot" > - {dial} + {dial(metrics.dialDiameter)} ) : null} diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index 53b42508..f14e5668 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -13,6 +13,7 @@ import { FieldOverlayLayout } from "./field-overlay-layout"; import { useFieldScreenController } from "./use-field-screen-controller"; import { CoordinatePanel } from "./coordinate-panel/coordinate-panel"; import { areCoordinatePanelControlsDisabled } from "./coordinate-panel/coordinate-panel-state"; +import { PageDial } from "./page-dial/page-dial"; export function FieldScreen({ livePosition, @@ -73,6 +74,24 @@ export function FieldScreen({ onToggleMetric={() => void controller.toggleMetricMode()} /> } + dial={ + controller.settings.drillFeaturesEnabled + ? (diameter) => ( + + void controller.selectPageAtIndex(index) + } + /> + ) + : undefined + } /> ); } diff --git a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts new file mode 100644 index 00000000..d6bcd9a2 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts @@ -0,0 +1,66 @@ +import { + PAGE_DIAL_START_ANGLE_DEGREES, + PAGE_DIAL_USABLE_ARC_DEGREES, + normalizePageIndex, + pageDialAngleForIndex, + pageDialIndexForAngle, +} from "../page-dial-math"; +import { + getPageDialAccessibilityLabel, + getPageDialControlState, + getPageDialProportions, +} from "../page-dial-layout"; + +const radians = (degrees: number) => (degrees * Math.PI) / 180; + +describe("page dial math", () => { + test("maps first and last pages to distinct arc endpoints", () => { + expect(normalizePageIndex(0, 38)).toBe(0); + expect(normalizePageIndex(37, 38)).toBe(1); + expect(pageDialAngleForIndex(0, 38)).toBeCloseTo( + radians(PAGE_DIAL_START_ANGLE_DEGREES), + ); + expect(pageDialAngleForIndex(37, 38)).toBeCloseTo( + radians(PAGE_DIAL_START_ANGLE_DEGREES + PAGE_DIAL_USABLE_ARC_DEGREES), + ); + }); + + test("clamps either side of the top seam to the nearest endpoint", () => { + expect(pageDialIndexForAngle(radians(-89), 38)).toBe(0); + expect(pageDialIndexForAngle(radians(-91), 38)).toBe(37); + expect(pageDialIndexForAngle(radians(90), 5)).toBe(2); + }); + + test("disables unavailable first and last actions", () => { + expect(getPageDialControlState(0, 4)).toEqual({ + previousDisabled: true, + nextDisabled: false, + }); + expect(getPageDialControlState(3, 4)).toEqual({ + previousDisabled: false, + nextDisabled: true, + }); + expect(getPageDialControlState(-1, 4)).toEqual({ + previousDisabled: true, + nextDisabled: false, + }); + }); + + test("uses supplied proportions and terminology-aware accessibility", () => { + const proportions = getPageDialProportions(100); + expect(proportions.ringThickness).toBeCloseTo(7); + expect(proportions.innerDiskDiameter).toBeCloseTo(86); + expect(proportions.centerDiskDiameter).toBeCloseTo(30); + expect(proportions.centerBorderWidth).toBeCloseTo(1.8); + expect(proportions.knobDiameter).toBeCloseTo(13); + expect(proportions.controlCenterOffset).toBeCloseTo(29); + expect( + getPageDialAccessibilityLabel({ + selectedIndex: 21, + selectedLabel: "22", + pageCount: 38, + terminology: "sets", + }), + ).toBe("Set selector, set 22 of 38"); + }); +}); diff --git a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx new file mode 100644 index 00000000..c79f71d7 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx @@ -0,0 +1,36 @@ +import { FieldPageDialCanvas } from "@eight2five/mobile/field/render"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import { + normalizePageIndex, + PAGE_DIAL_START_ANGLE_DEGREES, + PAGE_DIAL_USABLE_ARC_DEGREES, +} from "./page-dial-math"; + +export function PageDialCanvas({ + diameter, + pageCount, + provisionalIndex, + activeColor, + trackColor, +}: { + readonly diameter: number; + readonly pageCount: number; + readonly provisionalIndex: SharedValue; + readonly activeColor: string; + readonly trackColor: string; +}) { + const progress = useDerivedValue(() => + normalizePageIndex(provisionalIndex.value, pageCount), + ); + return ( + + ); +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx new file mode 100644 index 00000000..0d5441f7 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx @@ -0,0 +1,118 @@ +import { Center } from "@eight2five/ui/components/center"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { Minus, Plus } from "lucide-react-native"; +import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill"; + +import { + getPageDialAccessibilityLabel, + getPageDialControlState, + getPageDialProportions, +} from "./page-dial-layout"; + +export function PageDialControls({ + diameter, + selectedIndex, + selectedLabel, + pageCount, + terminology, + onPrevious, + onNext, +}: { + readonly diameter: number; + readonly selectedIndex: number; + readonly selectedLabel?: string; + readonly pageCount: number; + readonly terminology: DrillTerminology; + readonly onPrevious: () => void; + readonly onNext: () => void; +}) { + const terms = getDrillTerms(terminology); + const proportions = getPageDialProportions(diameter); + const state = getPageDialControlState(selectedIndex, pageCount); + const buttonSize = Math.max(48, diameter * 0.31); + const center = diameter / 2; + const previousCenter = center - proportions.controlCenterOffset; + const nextCenter = center + proportions.controlCenterOffset; + const centerDiameter = proportions.centerDiskDiameter; + + return ( + <> + + + +
+ + {terms.singular} + + + {selectedIndex >= 0 ? (selectedLabel ?? selectedIndex + 1) : "–"} + +
+ + + + + ); +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts new file mode 100644 index 00000000..bb75b383 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts @@ -0,0 +1,74 @@ +import React from "react"; +import * as Haptics from "expo-haptics"; +import { Gesture } from "react-native-gesture-handler"; +import { useSharedValue, type SharedValue } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; + +import { pageDialIndexForPoint } from "./page-dial-math"; + +function setSharedValue(sharedValue: SharedValue, value: T): void { + "worklet"; + sharedValue.value = value; +} + +export function triggerPageDialHaptic(): void { + void Haptics.selectionAsync().catch(() => undefined); +} + +export function usePageDialGesture({ + diameter, + pageCount, + provisionalIndex, + onCommitIndex, +}: { + readonly diameter: number; + readonly pageCount: number; + readonly provisionalIndex: SharedValue; + readonly onCommitIndex: (index: number) => void; +}) { + const ringActive = useSharedValue(false); + const gestureStartIndex = useSharedValue(0); + + const updateFromPoint = (x: number, y: number) => { + "worklet"; + if (!ringActive.value || pageCount <= 0) return; + const nextIndex = pageDialIndexForPoint(x, y, diameter, pageCount); + if (nextIndex === provisionalIndex.value) return; + setSharedValue(provisionalIndex, nextIndex); + scheduleOnRN(triggerPageDialHaptic); + }; + + const commitIndex = React.useCallback( + (index: number) => onCommitIndex(index), + [onCommitIndex], + ); + + return Gesture.Pan() + .withTestId("page-dial-ring-gesture") + .minDistance(1) + .onBegin((event) => { + const center = diameter / 2; + const radialDistance = Math.hypot(event.x - center, event.y - center); + const touchesRing = + radialDistance >= diameter * 0.455 && radialDistance <= diameter * 0.57; + setSharedValue(ringActive, touchesRing && pageCount > 0); + setSharedValue(gestureStartIndex, provisionalIndex.value); + updateFromPoint(event.x, event.y); + }) + .onUpdate((event) => updateFromPoint(event.x, event.y)) + .onEnd((_event, success) => { + if ( + success && + ringActive.value && + provisionalIndex.value !== gestureStartIndex.value + ) { + scheduleOnRN(commitIndex, provisionalIndex.value); + } + }) + .onFinalize((_event, success) => { + if (!success && ringActive.value) { + setSharedValue(provisionalIndex, gestureStartIndex.value); + } + setSharedValue(ringActive, false); + }); +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial-layout.ts b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts new file mode 100644 index 00000000..60d1c497 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts @@ -0,0 +1,50 @@ +import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill"; + +export interface PageDialProportions { + readonly ringThickness: number; + readonly innerDiskDiameter: number; + readonly centerDiskDiameter: number; + readonly centerBorderWidth: number; + readonly knobDiameter: number; + readonly controlCenterOffset: number; +} + +export function getPageDialProportions(diameter: number): PageDialProportions { + return { + ringThickness: diameter * 0.07, + innerDiskDiameter: diameter * 0.86, + centerDiskDiameter: diameter * 0.3, + centerBorderWidth: diameter * 0.018, + knobDiameter: diameter * 0.13, + controlCenterOffset: diameter * 0.29, + }; +} + +export function getPageDialControlState( + selectedIndex: number, + pageCount: number, +): { previousDisabled: boolean; nextDisabled: boolean } { + return { + previousDisabled: selectedIndex <= 0, + nextDisabled: + pageCount <= 0 || (selectedIndex >= 0 && selectedIndex >= pageCount - 1), + }; +} + +export function getPageDialAccessibilityLabel({ + selectedIndex, + selectedLabel, + pageCount, + terminology, +}: { + readonly selectedIndex: number; + readonly selectedLabel?: string; + readonly pageCount: number; + readonly terminology: DrillTerminology; +}): string { + const terms = getDrillTerms(terminology); + if (selectedIndex < 0) { + return `${terms.singular} selector, no ${terms.lowercaseSingular} selected, ${pageCount} available`; + } + return `${terms.singular} selector, ${terms.lowercaseSingular} ${selectedLabel ?? selectedIndex + 1} of ${pageCount}`; +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial-math.ts b/apps/mobile/src/features/field/page-dial/page-dial-math.ts new file mode 100644 index 00000000..2834a790 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-math.ts @@ -0,0 +1,61 @@ +export const PAGE_DIAL_DEAD_ZONE_DEGREES = 10; +export const PAGE_DIAL_USABLE_ARC_DEGREES = 360 - PAGE_DIAL_DEAD_ZONE_DEGREES; +export const PAGE_DIAL_START_ANGLE_DEGREES = + -90 + PAGE_DIAL_DEAD_ZONE_DEGREES / 2; + +const FULL_TURN_RADIANS = Math.PI * 2; + +export function normalizePageIndex(index: number, pageCount: number): number { + "worklet"; + if (pageCount <= 1) return 0; + return Math.min(1, Math.max(0, index / (pageCount - 1))); +} + +export function pageDialAngleForIndex( + index: number, + pageCount: number, +): number { + "worklet"; + return ( + ((PAGE_DIAL_START_ANGLE_DEGREES + + normalizePageIndex(index, pageCount) * PAGE_DIAL_USABLE_ARC_DEGREES) * + Math.PI) / + 180 + ); +} + +export function pageDialProgressForAngle(angleRadians: number): number { + "worklet"; + const start = (PAGE_DIAL_START_ANGLE_DEGREES * Math.PI) / 180; + const usableArc = (PAGE_DIAL_USABLE_ARC_DEGREES * Math.PI) / 180; + const rawRelative = (angleRadians - start) % FULL_TURN_RADIANS; + const relative = + rawRelative < 0 ? rawRelative + FULL_TURN_RADIANS : rawRelative; + if (relative <= usableArc) return relative / usableArc; + + // Touches in the top dead zone clamp to the nearest endpoint. This avoids + // wrapping directly from the first page to the last across ±π. + const distanceFromEnd = relative - usableArc; + const distanceFromStart = FULL_TURN_RADIANS - relative; + return distanceFromStart <= distanceFromEnd ? 0 : 1; +} + +export function pageDialIndexForAngle( + angleRadians: number, + pageCount: number, +): number { + "worklet"; + if (pageCount <= 1) return 0; + return Math.round(pageDialProgressForAngle(angleRadians) * (pageCount - 1)); +} + +export function pageDialIndexForPoint( + x: number, + y: number, + diameter: number, + pageCount: number, +): number { + "worklet"; + const center = diameter / 2; + return pageDialIndexForAngle(Math.atan2(y - center, x - center), pageCount); +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial.tsx b/apps/mobile/src/features/field/page-dial/page-dial.tsx new file mode 100644 index 00000000..f225d47c --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { View } from "react-native"; +import { GestureDetector } from "react-native-gesture-handler"; +import { + useSharedValue, + withTiming, + type SharedValue, +} from "react-native-reanimated"; +import type { DrillTerminology } from "@eight2five/mobile/drill"; + +import { PageDialCanvas } from "./page-dial-canvas"; +import { PageDialControls } from "./page-dial-controls"; +import { triggerPageDialHaptic, usePageDialGesture } from "./page-dial-gesture"; + +function animateIndex(sharedValue: SharedValue, index: number): void { + sharedValue.value = withTiming(index, { duration: 120 }); +} + +export function PageDial({ + diameter, + selectedIndex, + selectedLabel, + pageCount, + terminology, + activeColor, + trackColor, + onSelectIndex, +}: { + readonly diameter: number; + readonly selectedIndex: number; + readonly selectedLabel?: string; + readonly pageCount: number; + readonly terminology: DrillTerminology; + readonly activeColor: string; + readonly trackColor: string; + readonly onSelectIndex: (index: number) => void; +}) { + const provisionalIndex = useSharedValue(Math.max(0, selectedIndex)); + React.useEffect(() => { + animateIndex(provisionalIndex, Math.max(0, selectedIndex)); + }, [provisionalIndex, selectedIndex]); + + const gesture = usePageDialGesture({ + diameter, + pageCount, + provisionalIndex, + onCommitIndex: onSelectIndex, + }); + const selectFromButton = React.useCallback( + (index: number) => { + const bounded = Math.max(0, Math.min(pageCount - 1, index)); + if (bounded === selectedIndex) return; + animateIndex(provisionalIndex, bounded); + triggerPageDialHaptic(); + onSelectIndex(bounded); + }, + [onSelectIndex, pageCount, provisionalIndex, selectedIndex], + ); + + return ( + + + + selectFromButton(selectedIndex - 1)} + onNext={() => + selectFromButton(selectedIndex < 0 ? 0 : selectedIndex + 1) + } + /> + + + ); +} diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts index e56474af..d2d31a66 100644 --- a/apps/mobile/src/features/field/use-field-screen-controller.ts +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -29,7 +29,12 @@ export function useFieldScreenController() { const [loadingDrills, setLoadingDrills] = React.useState(true); const [fieldError, setFieldError] = React.useState(); const [selectionBusy, setSelectionBusy] = React.useState(false); + const [optimisticSelection, setOptimisticSelection] = React.useState<{ + readonly activeDrillId: string | null; + readonly pageId: string; + }>(); const refreshGeneration = React.useRef(0); + const pageSelectionGeneration = React.useRef(0); const commitViewport = React.useCallback((viewport: FieldViewport) => { committedFieldViewport = viewport; }, []); @@ -97,8 +102,39 @@ export function useFieldScreenController() { } }, [snapshot.settings.transitionMetricMode, snapshot.status, store]); + const selectPageAtIndex = React.useCallback( + async (index: number) => { + const page = pages[index]; + if (!page || snapshot.status !== "ready") return; + const generation = ++pageSelectionGeneration.current; + setOptimisticSelection({ + activeDrillId: snapshot.settings.activeDrillId, + pageId: page.id, + }); + setFieldError(undefined); + try { + await store.setSelectedDrillPage(page.id); + if (generation === pageSelectionGeneration.current) { + setOptimisticSelection(undefined); + } + } catch (cause) { + if (generation === pageSelectionGeneration.current) { + setOptimisticSelection(undefined); + setFieldError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } + } + }, + [pages, snapshot.settings.activeDrillId, snapshot.status, store], + ); + + const effectiveSelectedPageId = + optimisticSelection?.activeDrillId === snapshot.settings.activeDrillId + ? optimisticSelection.pageId + : snapshot.settings.selectedDrillPageId; const selectedIndex = pages.findIndex( - (page) => page.id === snapshot.settings.selectedDrillPageId, + (page) => page.id === effectiveSelectedPageId, ); const selectedPage = selectedIndex >= 0 ? pages[selectedIndex] : undefined; @@ -121,6 +157,7 @@ export function useFieldScreenController() { error: fieldError ?? snapshot.error, selectActiveDrill, toggleMetricMode, + selectPageAtIndex, refreshDrills, } as const; } diff --git a/packages/mobile/src/field/render/index.ts b/packages/mobile/src/field/render/index.ts index 8d28c231..6c287b5d 100644 --- a/packages/mobile/src/field/render/index.ts +++ b/packages/mobile/src/field/render/index.ts @@ -1,3 +1,4 @@ export * from "./create-field-paths"; export * from "./field-render-tokens"; export * from "./field-canvas"; +export * from "./page-dial-canvas"; diff --git a/packages/mobile/src/field/render/page-dial-canvas.tsx b/packages/mobile/src/field/render/page-dial-canvas.tsx new file mode 100644 index 00000000..ddb6fb56 --- /dev/null +++ b/packages/mobile/src/field/render/page-dial-canvas.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { Canvas, Circle, Group, Path, Skia } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +export interface FieldPageDialCanvasProps { + readonly diameter: number; + readonly progress: SharedValue; + readonly startAngleDegrees: number; + readonly usableArcDegrees: number; + readonly activeColor: string; + readonly trackColor: string; + readonly innerColor?: string; + readonly foregroundColor?: string; + readonly testID?: string; +} + +export function FieldPageDialCanvas({ + diameter, + progress, + startAngleDegrees, + usableArcDegrees, + activeColor, + trackColor, + innerColor = "#222222", + foregroundColor = "#FFFFFF", + testID = "page-dial-canvas", +}: FieldPageDialCanvasProps) { + const center = diameter / 2; + const ringThickness = diameter * 0.07; + const ringRadius = diameter / 2 - ringThickness / 2; + const trackPath = React.useMemo(() => { + const path = Skia.Path.Make(); + const inset = ringThickness / 2; + path.addArc( + Skia.XYWHRect( + inset, + inset, + diameter - ringThickness, + diameter - ringThickness, + ), + startAngleDegrees, + usableArcDegrees, + ); + return path; + }, [diameter, ringThickness, startAngleDegrees, usableArcDegrees]); + const knobX = useDerivedValue(() => { + const angle = + ((startAngleDegrees + progress.value * usableArcDegrees) * Math.PI) / 180; + return center + Math.cos(angle) * ringRadius; + }); + const knobY = useDerivedValue(() => { + const angle = + ((startAngleDegrees + progress.value * usableArcDegrees) * Math.PI) / 180; + return center + Math.sin(angle) * ringRadius; + }); + + return ( + + + + + + + + + + + ); +} From 57ff9d8b29819ef963ae41505edb69837a02bd88 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Fri, 31 Jul 2026 23:45:43 -0500 Subject: [PATCH 016/101] feat(field): add drill target and guidance --- .../__tests__/field-overlay-layout.test.ts | 5 +- .../transition-metric-cell.tsx | 1 + .../features/field/field-overlay-layout.tsx | 3 +- .../src/features/field/field-screen.tsx | 55 ++++++++++++- .../__tests__/field-overlay-policy.test.ts | 37 +++++++++ packages/mobile/src/field/index.ts | 1 + .../src/field/render/field-anchor-layer.tsx | 77 +++++++++++++++++++ .../mobile/src/field/render/field-canvas.tsx | 25 +++++- .../src/field/render/field-guidance-layer.tsx | 38 +++++++++ .../src/field/render/field-overlay-types.ts | 43 +++++++++++ .../src/field/render/field-position-layer.tsx | 60 +++++++++++++++ .../mobile/src/field/render/field-scene.tsx | 41 +++++++++- packages/mobile/src/field/render/index.ts | 1 + packages/mobile/src/index.ts | 1 + 14 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 packages/mobile/src/field/__tests__/field-overlay-policy.test.ts create mode 100644 packages/mobile/src/field/render/field-anchor-layer.tsx create mode 100644 packages/mobile/src/field/render/field-guidance-layer.tsx create mode 100644 packages/mobile/src/field/render/field-overlay-types.ts create mode 100644 packages/mobile/src/field/render/field-position-layer.tsx diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts index ab4b56b2..efd9da62 100644 --- a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts +++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts @@ -32,6 +32,9 @@ describe("Field overlay layout", () => { expect(layout.hudStyle.left).toBe(22); expect(layout.hudStyle.right).toBe(22); expect(layout.dialStyle.bottom).toBe(32); - expect(layout.dialStyle.left).toBe((390 - layout.dialDiameter) / 2); + expect(layout.dialStyle.left).toBe( + insets.left + + (390 - insets.left - insets.right - layout.dialDiameter) / 2, + ); }); }); diff --git a/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx b/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx index 27997885..5bffc72a 100644 --- a/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx +++ b/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx @@ -19,6 +19,7 @@ export function TransitionMetricCell({ , + position: FieldPoint | null, +): void { + sharedValue.value = position; +} + export function FieldScreen({ livePosition, + anchors = [], + anchorOverlayOptions, }: { readonly livePosition?: FieldLivePositionInput; + readonly anchors?: readonly FieldAnchorGeometry[]; + readonly anchorOverlayOptions?: FieldAnchorOverlayOptions; }) { const theme = useEight2FiveTheme(); const controller = useFieldScreenController(); + const liveState = livePosition?.state ?? EMPTY_FIELD_LIVE_POSITION_STATE; + const fallbackLivePosition = useSharedValue( + liveState.position ?? null, + ); + const livePositionValue = livePosition?.positionValue ?? fallbackLivePosition; + const liveXMeters = liveState.position?.xMeters; + const liveYMeters = liveState.position?.yMeters; + React.useEffect(() => { + if (livePosition?.positionValue) return; + setLivePositionValue( + fallbackLivePosition, + liveXMeters === undefined || liveYMeters === undefined + ? null + : { xMeters: liveXMeters, yMeters: liveYMeters }, + ); + }, [ + fallbackLivePosition, + livePosition?.positionValue, + liveXMeters, + liveYMeters, + ]); + const drillOverlayState = { + drillFeaturesEnabled: controller.settings.drillFeaturesEnabled, + hasActiveDrill: Boolean(controller.activeDrill), + hasSelectedPage: Boolean(controller.selectedPage), + hasLivePosition: Boolean(liveState.position) && !liveState.isStale, + guidanceEnabled: controller.settings.guidanceEnabled, + }; + const targetPosition = shouldShowFieldTarget(drillOverlayState) + ? controller.selectedPage?.position + : undefined; const palette = React.useMemo( () => ({ canvasBackground: theme.background, @@ -49,12 +97,17 @@ export function FieldScreen({ defaultViewport={controller.defaultViewport} onViewportChange={controller.commitViewport} palette={palette} + livePosition={livePositionValue} + targetPosition={targetPosition} + guidanceVisible={shouldShowFieldGuidance(drillOverlayState)} + anchors={anchors} + anchorOverlayOptions={anchorOverlayOptions} /> } hud={ { + test("shows target and guidance only for a complete active selection", () => { + expect(shouldShowFieldTarget(visible)).toBe(true); + expect(shouldShowFieldGuidance(visible)).toBe(true); + }); + + test.each([ + "drillFeaturesEnabled", + "hasActiveDrill", + "hasSelectedPage", + ] as const)("hides target when %s is false", (key) => { + expect(shouldShowFieldTarget({ ...visible, [key]: false })).toBe(false); + }); + + test("hides guidance without live position or when guidance is disabled", () => { + expect( + shouldShowFieldGuidance({ ...visible, hasLivePosition: false }), + ).toBe(false); + expect( + shouldShowFieldGuidance({ ...visible, guidanceEnabled: false }), + ).toBe(false); + }); +}); diff --git a/packages/mobile/src/field/index.ts b/packages/mobile/src/field/index.ts index 1f4444a2..5e744457 100644 --- a/packages/mobile/src/field/index.ts +++ b/packages/mobile/src/field/index.ts @@ -9,3 +9,4 @@ export * from "./camera/field-camera-math"; export * from "./camera/field-camera-policy"; export * from "./render/create-field-paths"; export * from "./render/field-render-tokens"; +export * from "./render/field-overlay-types"; diff --git a/packages/mobile/src/field/render/field-anchor-layer.tsx b/packages/mobile/src/field/render/field-anchor-layer.tsx new file mode 100644 index 00000000..c58fe45b --- /dev/null +++ b/packages/mobile/src/field/render/field-anchor-layer.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { Circle, Group, Path } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { + FieldAnchorGeometry, + FieldAnchorOverlayOptions, +} from "./field-overlay-types"; +import type { FieldRenderPalette } from "./field-render-tokens"; + +export const FieldAnchorLayer = React.memo(function FieldAnchorLayer({ + anchors, + options, + metersPerPixel, + palette, +}: { + readonly anchors: readonly FieldAnchorGeometry[]; + readonly options: FieldAnchorOverlayOptions; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; +}) { + const rangeStrokeWidth = useDerivedValue(() => metersPerPixel.value); + if (!options.visible) return null; + + return ( + <> + {options.showRange && options.rangeMeters > 0 + ? anchors.map((anchor) => ( + + )) + : null} + {anchors.map((anchor) => ( + + ))} + + ); +}); + +function AnchorMarker({ + anchor, + metersPerPixel, + color, +}: { + readonly anchor: FieldAnchorGeometry; + readonly metersPerPixel: SharedValue; + readonly color: string; +}) { + const transform = useDerivedValue(() => [ + { translateX: anchor.position.xMeters }, + { translateY: anchor.position.yMeters }, + { scaleX: metersPerPixel.value }, + { scaleY: -metersPerPixel.value }, + ]); + return ( + + + + ); +} diff --git a/packages/mobile/src/field/render/field-canvas.tsx b/packages/mobile/src/field/render/field-canvas.tsx index c12f7b23..ef5601f4 100644 --- a/packages/mobile/src/field/render/field-canvas.tsx +++ b/packages/mobile/src/field/render/field-canvas.tsx @@ -7,13 +7,14 @@ import { } from "react-native"; import { Canvas, Fill } from "@shopify/react-native-skia"; import { GestureDetector } from "react-native-gesture-handler"; -import { useSharedValue } from "react-native-reanimated"; +import { useSharedValue, type SharedValue } from "react-native-reanimated"; import { setFieldCamera } from "../camera/field-camera-math"; import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, type StandardHighSchoolFieldTemplate, } from "../template"; +import type { FieldPoint } from "../types"; import { getFieldCameraBounds, getFieldGridBounds, @@ -27,6 +28,11 @@ import type { import { useFieldGestures } from "../camera/use-field-gestures"; import { createFieldPaths } from "./create-field-paths"; import { FieldScene } from "./field-scene"; +import { + HIDDEN_FIELD_ANCHOR_OVERLAY, + type FieldAnchorGeometry, + type FieldAnchorOverlayOptions, +} from "./field-overlay-types"; import { DEFAULT_FIELD_RENDER_PALETTE, type FieldRenderPalette, @@ -38,6 +44,11 @@ export interface FieldCanvasProps { readonly defaultViewport?: FieldViewport; readonly onViewportChange?: (viewport: FieldViewport) => void; readonly palette?: FieldRenderPalette; + readonly livePosition?: SharedValue; + readonly targetPosition?: FieldPoint; + readonly guidanceVisible?: boolean; + readonly anchors?: readonly FieldAnchorGeometry[]; + readonly anchorOverlayOptions?: FieldAnchorOverlayOptions; readonly style?: StyleProp; readonly testID?: string; } @@ -48,6 +59,11 @@ export function FieldCanvas({ defaultViewport, onViewportChange, palette = DEFAULT_FIELD_RENDER_PALETTE, + livePosition: externalLivePosition, + targetPosition, + guidanceVisible = false, + anchors = [], + anchorOverlayOptions = HIDDEN_FIELD_ANCHOR_OVERLAY, style, testID = "field-canvas", }: FieldCanvasProps) { @@ -70,6 +86,8 @@ export function FieldCanvas({ ); const camera = externalCamera ?? internalCamera; const canvasSize = useSharedValue({ width: 0, height: 0 }); + const emptyLivePosition = useSharedValue(null); + const livePosition = externalLivePosition ?? emptyLivePosition; const initialized = React.useRef(Boolean(externalCamera || defaultViewport)); const paths = React.useMemo(() => createFieldPaths(template), [template]); const cameraBounds = React.useMemo( @@ -132,6 +150,11 @@ export function FieldCanvas({ template={template} paths={paths} palette={palette} + livePosition={livePosition} + targetPosition={targetPosition} + guidanceVisible={guidanceVisible} + anchors={anchors} + anchorOverlayOptions={anchorOverlayOptions} /> diff --git a/packages/mobile/src/field/render/field-guidance-layer.tsx b/packages/mobile/src/field/render/field-guidance-layer.tsx new file mode 100644 index 00000000..e14f9046 --- /dev/null +++ b/packages/mobile/src/field/render/field-guidance-layer.tsx @@ -0,0 +1,38 @@ +import { Line } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { FieldPoint } from "../types"; + +export function FieldGuidanceLayer({ + livePosition, + targetPosition, + metersPerPixel, + color, +}: { + readonly livePosition: SharedValue; + readonly targetPosition: FieldPoint; + readonly metersPerPixel: SharedValue; + readonly color: string; +}) { + const livePoint = useDerivedValue(() => { + const position = livePosition.value ?? targetPosition; + return { x: position.xMeters, y: position.yMeters }; + }, [targetPosition.xMeters, targetPosition.yMeters]); + const targetPoint = { + x: targetPosition.xMeters, + y: targetPosition.yMeters, + }; + const opacity = useDerivedValue(() => + livePosition.value === null ? 0 : 0.82, + ); + const strokeWidth = useDerivedValue(() => metersPerPixel.value * 1.25); + return ( + + ); +} diff --git a/packages/mobile/src/field/render/field-overlay-types.ts b/packages/mobile/src/field/render/field-overlay-types.ts new file mode 100644 index 00000000..46f482d0 --- /dev/null +++ b/packages/mobile/src/field/render/field-overlay-types.ts @@ -0,0 +1,43 @@ +import type { FieldPosition } from "../types"; + +export interface FieldAnchorGeometry { + readonly id: string; + readonly position: FieldPosition; +} + +export interface FieldAnchorOverlayOptions { + readonly visible: boolean; + readonly showRange: boolean; + readonly rangeMeters: number; +} + +export const HIDDEN_FIELD_ANCHOR_OVERLAY: FieldAnchorOverlayOptions = + Object.freeze({ + visible: false, + showRange: false, + rangeMeters: 0, + }); + +export interface FieldDrillOverlayState { + readonly drillFeaturesEnabled: boolean; + readonly hasActiveDrill: boolean; + readonly hasSelectedPage: boolean; + readonly hasLivePosition: boolean; + readonly guidanceEnabled: boolean; +} + +export function shouldShowFieldTarget(state: FieldDrillOverlayState): boolean { + return ( + state.drillFeaturesEnabled && state.hasActiveDrill && state.hasSelectedPage + ); +} + +export function shouldShowFieldGuidance( + state: FieldDrillOverlayState, +): boolean { + return ( + shouldShowFieldTarget(state) && + state.hasLivePosition && + state.guidanceEnabled + ); +} diff --git a/packages/mobile/src/field/render/field-position-layer.tsx b/packages/mobile/src/field/render/field-position-layer.tsx new file mode 100644 index 00000000..f334b73e --- /dev/null +++ b/packages/mobile/src/field/render/field-position-layer.tsx @@ -0,0 +1,60 @@ +import { Circle, Group, Path } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { FieldPoint } from "../types"; +import type { FieldRenderPalette } from "./field-render-tokens"; + +export function FieldPositionLayer({ + livePosition, + targetPosition, + metersPerPixel, + palette, +}: { + readonly livePosition: SharedValue; + readonly targetPosition?: FieldPoint; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; +}) { + const liveTransform = useDerivedValue(() => { + const position = livePosition.value; + const scale = metersPerPixel.value; + return [ + { translateX: position?.xMeters ?? -1_000_000 }, + { translateY: position?.yMeters ?? -1_000_000 }, + { scaleX: scale }, + { scaleY: -scale }, + ]; + }); + const liveOpacity = useDerivedValue(() => + livePosition.value === null ? 0 : 1, + ); + const targetTransform = useDerivedValue(() => { + const scale = metersPerPixel.value; + return [ + { translateX: targetPosition?.xMeters ?? -1_000_000 }, + { translateY: targetPosition?.yMeters ?? -1_000_000 }, + { scaleX: scale }, + { scaleY: -scale }, + ]; + }, [targetPosition?.xMeters, targetPosition?.yMeters]); + + return ( + <> + {targetPosition ? ( + + + + + ) : null} + + + + + + ); +} diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx index 52db769b..9f45b8ce 100644 --- a/packages/mobile/src/field/render/field-scene.tsx +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -3,12 +3,20 @@ import { Group } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import type { StandardHighSchoolFieldTemplate } from "../template"; +import type { FieldPoint } from "../types"; import type { FieldCamera, FieldViewportSize, } from "../camera/field-camera-types"; import type { FieldPaths } from "./create-field-paths"; import { FieldStaticLayer } from "./field-static-layer"; +import { FieldAnchorLayer } from "./field-anchor-layer"; +import { FieldGuidanceLayer } from "./field-guidance-layer"; +import { FieldPositionLayer } from "./field-position-layer"; +import type { + FieldAnchorGeometry, + FieldAnchorOverlayOptions, +} from "./field-overlay-types"; import type { FieldRenderPalette } from "./field-render-tokens"; interface FieldSceneProps { @@ -17,7 +25,11 @@ interface FieldSceneProps { readonly template: StandardHighSchoolFieldTemplate; readonly paths: FieldPaths; readonly palette: FieldRenderPalette; - readonly children?: React.ReactNode; + readonly livePosition: SharedValue; + readonly targetPosition?: FieldPoint; + readonly guidanceVisible: boolean; + readonly anchors: readonly FieldAnchorGeometry[]; + readonly anchorOverlayOptions: FieldAnchorOverlayOptions; } export function FieldScene({ @@ -26,7 +38,11 @@ export function FieldScene({ template, paths, palette, - children, + livePosition, + targetPosition, + guidanceVisible, + anchors, + anchorOverlayOptions, }: FieldSceneProps) { const cameraTransform = useDerivedValue(() => [ { translateX: canvasSize.value.width / 2 }, @@ -45,7 +61,26 @@ export function FieldScene({ metersPerPixel={camera.metersPerPixel} palette={palette} /> - {children} + + {guidanceVisible && targetPosition ? ( + + ) : null} + ); } diff --git a/packages/mobile/src/field/render/index.ts b/packages/mobile/src/field/render/index.ts index 6c287b5d..d88ce3d0 100644 --- a/packages/mobile/src/field/render/index.ts +++ b/packages/mobile/src/field/render/index.ts @@ -2,3 +2,4 @@ export * from "./create-field-paths"; export * from "./field-render-tokens"; export * from "./field-canvas"; export * from "./page-dial-canvas"; +export * from "./field-overlay-types"; diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 7930a76d..9badd5d2 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -38,6 +38,7 @@ export * from "./field/camera/field-camera-math"; export * from "./field/camera/field-camera-policy"; export * from "./field/render/create-field-paths"; export * from "./field/render/field-render-tokens"; +export * from "./field/render/field-overlay-types"; export * from "./drill"; export * from "./settings"; export * from "./storage"; From 6cd319b0f6ce94b8c88703629f94800f4d12d93e Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sat, 1 Aug 2026 00:45:38 -0500 Subject: [PATCH 017/101] feat(pans): connect live tag position to field --- apps/mobile/app/(tabs)/field/index.tsx | 4 +- apps/mobile/app/(tabs)/settings/_layout.tsx | 1 + apps/mobile/app/(tabs)/settings/tag.tsx | 5 + apps/mobile/app/_layout.tsx | 5 +- .../src/features/settings/settings-screen.tsx | 17 +- .../settings/tag-connection-screen.tsx | 231 ++++++ .../pans/__tests__/mobile-pans-store.test.ts | 262 +++++++ apps/mobile/src/pans/mobile-pans-context.tsx | 93 +++ apps/mobile/src/pans/mobile-pans-runtime.ts | 60 ++ apps/mobile/src/pans/mobile-pans-store.ts | 714 ++++++++++++++++++ .../pans-manager/PansDeviceSessionManager.ts | 19 + .../pans-manager/__tests__/session.test.ts | 15 + .../pans-manager/__tests__/settings.test.ts | 9 + packages/mobile/src/pans-manager/types.ts | 9 + 14 files changed, 1439 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/app/(tabs)/settings/tag.tsx create mode 100644 apps/mobile/src/features/settings/tag-connection-screen.tsx create mode 100644 apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts create mode 100644 apps/mobile/src/pans/mobile-pans-context.tsx create mode 100644 apps/mobile/src/pans/mobile-pans-runtime.ts create mode 100644 apps/mobile/src/pans/mobile-pans-store.ts diff --git a/apps/mobile/app/(tabs)/field/index.tsx b/apps/mobile/app/(tabs)/field/index.tsx index 836d78bc..59a867aa 100644 --- a/apps/mobile/app/(tabs)/field/index.tsx +++ b/apps/mobile/app/(tabs)/field/index.tsx @@ -1,5 +1,7 @@ import { FieldScreen } from "../../../src/features/field/field-screen"; +import { useFieldLivePosition } from "../../../src/pans/mobile-pans-context"; export default function FieldRoute() { - return ; + const livePosition = useFieldLivePosition(); + return ; } diff --git a/apps/mobile/app/(tabs)/settings/_layout.tsx b/apps/mobile/app/(tabs)/settings/_layout.tsx index d35451d9..2b7b4216 100644 --- a/apps/mobile/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile/app/(tabs)/settings/_layout.tsx @@ -19,6 +19,7 @@ export default function SettingsLayout() { > + ; +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index ae20da2e..2f61aeee 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -12,6 +12,7 @@ import { AppSettingsProvider, useAppSettingsSnapshot, } from "../src/state/app-settings-store"; +import { MobilePansProvider } from "../src/pans/mobile-pans-context"; import "../global.css"; @@ -35,7 +36,9 @@ export default function MobileRootLayout() { - + + + diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 982ed47d..94bab8f7 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -15,6 +15,7 @@ import type { } from "@eight2five/mobile/settings"; import { useTabBarVisibility } from "../../navigation/tab-bar-visibility-context"; +import { useMobilePansSnapshot } from "../../pans/mobile-pans-context"; import { useAppSettingsSnapshot, useAppSettingsStore, @@ -46,6 +47,7 @@ export function SettingsScreen() { const store = useAppSettingsStore(); const { status, settings, error: loadError } = useAppSettingsSnapshot(); const { reconfigureDrillFeatures } = useTabBarVisibility(); + const pans = useMobilePansSnapshot(); const [operationError, setOperationError] = React.useState(); const disabled = status !== "ready"; @@ -79,11 +81,20 @@ export function SettingsScreen() { ) : null} - router.push("/(tabs)/settings/tag")} + testID="tag-connection-link" + /> + diff --git a/apps/mobile/src/features/settings/tag-connection-screen.tsx b/apps/mobile/src/features/settings/tag-connection-screen.tsx new file mode 100644 index 00000000..4b0a1069 --- /dev/null +++ b/apps/mobile/src/features/settings/tag-connection-screen.tsx @@ -0,0 +1,231 @@ +import React from "react"; +import { + Bluetooth, + BluetoothConnected, + BluetoothOff, + RefreshCw, + Trash2, + TriangleAlert, +} from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +const BUSY_STATES = new Set(["scanning", "connecting", "reconnecting"]); + +export function TagConnectionScreen() { + const theme = useEight2FiveTheme(); + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const [operation, setOperation] = React.useState(); + const [error, setError] = React.useState(); + const busy = BUSY_STATES.has(snapshot.connectionState) || Boolean(operation); + const candidates = snapshot.discoveries.filter( + (device) => device.presence?.role !== "anchor", + ); + + const run = async (name: string, action: () => Promise) => { + if (operation) return; + setOperation(name); + setError(undefined); + try { + await action(); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setOperation(undefined); + } + }; + + return ( + + {snapshot.initialization === "loading" ? ( + Preparing PANS services… + ) : null} + {snapshot.error || error ? ( + + {(error ?? snapshot.error)?.message} + + ) : null} + + + + {snapshot.rememberedTag?.nodeIdHex ? ( + + ) : null} + + + + + + + + + + + + + + + {snapshot.connectionState === "scanning" || candidates.length > 0 ? ( + + {candidates.length === 0 ? ( + + + + Scanning for compatible tags… + + + ) : ( + candidates.map((device) => ( + + void run("select", async () => { + await store.selectTag(device.transportDeviceId); + await store.stopDiscovery(); + }) + } + > + + + + + {device.name ?? "PANS Tag"} + + + {device.transportDeviceId} + + + + {device.rssi} dBm + + + + )) + )} + + ) : null} + + {snapshot.connectionState === "error" ? ( + + + + Move closer to the tag, verify Bluetooth is enabled, then reconnect. + + + ) : null} + + ); +} + +function connectionIcon(state: string) { + if (state === "connected") return BluetoothConnected; + if (state === "disconnected" || state === "error") return BluetoothOff; + return Bluetooth; +} diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts new file mode 100644 index 00000000..70fa5746 --- /dev/null +++ b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts @@ -0,0 +1,262 @@ +import { InMemoryPansManagerRepository } from "@eight2five/mobile/pans-manager"; +import type { + DiscoveredDeviceSnapshot, + ManagedDevice, + PansPositionStreamSample, + StartPansPositionStreamOptions, +} from "@eight2five/mobile/pans-manager"; +import type { SharedValue } from "react-native-reanimated"; +import type { FieldPoint } from "@eight2five/mobile/field"; + +import type { MobilePansRuntime } from "../mobile-pans-runtime"; +import { + MobilePansStore, + pansPositionToFieldPoint, +} from "../mobile-pans-store"; + +jest.mock("expo-pans-ble-api", () => ({})); +jest.mock("react-native-worklets", () => ({ + ...jest.requireActual("react-native-worklets/lib/module/mock"), + scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) => + callback(...args), +})); +jest.mock("react-native-reanimated", () => + jest.requireActual("react-native-reanimated/mock"), +); +jest.mock( + "@shopify/react-native-skia", + () => ({ + Canvas: () => null, + Fill: () => null, + Group: () => null, + Path: () => null, + Circle: () => null, + Line: () => null, + Rect: () => null, + useFont: () => ({}), + vec: (x: number, y: number) => ({ x, y }), + }), + { virtual: true }, +); + +const DISCOVERY: DiscoveredDeviceSnapshot = { + transportDeviceId: "tag-transport", + name: "Field Tag", + rssi: -48, + lastSeenAt: 1, + stale: false, + compatibility: "compatible", + presence: { role: "tag" } as never, +}; + +describe("MobilePansStore", () => { + afterEach(() => jest.useRealTimers()); + + test("shares one connection attempt and one position stream", async () => { + const start = deferred(); + const harness = await createHarness({ + streamStart: jest.fn( + async (_options: StartPansPositionStreamOptions) => await start.promise, + ), + }); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + + const first = store.connect(); + const second = store.connect(); + await Promise.resolve(); + expect(harness.streamStart).toHaveBeenCalledTimes(1); + + start.resolve(); + await Promise.all([first, second]); + expect(store.getSnapshot().connectionState).toBe("connected"); + await store.dispose(); + }); + + test("cancels bounded reconnect after explicit disconnect", async () => { + jest.useFakeTimers(); + const remembered = managedTag(); + const streamStart = jest.fn( + async (_options: StartPansPositionStreamOptions) => { + throw new Error("offline"); + }, + ); + const harness = await createHarness({ remembered, streamStart }); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + reconnectDelaysMs: [100, 200], + }); + + await store.initialize(); + await flushPromises(); + expect(streamStart).toHaveBeenCalledTimes(1); + await store.disconnect(); + jest.advanceTimersByTime(1_000); + await flushPromises(); + + expect(streamStart).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().connectionState).toBe("disconnected"); + await store.dispose(); + }); + + test("updates the shared marker immediately and marks old data stale", async () => { + jest.useFakeTimers(); + const harness = await createHarness(); + const marker = { value: null } as SharedValue; + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + staleAfterMs: 500, + }); + store.attachPositionValue(marker); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + + harness.emitSample(positionSample(1_000, 12.5, 7.25, 1.8)); + expect(marker.value).toEqual({ xMeters: 12.5, yMeters: 7.25 }); + expect(store.getSnapshot()).toMatchObject({ + connectionState: "connected", + rawPosition: { xMeters: 12.5, yMeters: 7.25, zMeters: 1.8 }, + livePosition: { isStale: false }, + }); + + jest.advanceTimersByTime(500); + expect(marker.value).toBeNull(); + expect(store.getSnapshot().livePosition).toMatchObject({ + position: { xMeters: 12.5, yMeters: 7.25 }, + isStale: true, + }); + await store.dispose(); + }); + + test("forgets persisted identity without deleting the device cache", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + const selectedId = store.getSnapshot().rememberedTag?.id; + + await store.forgetTag(); + + expect( + (await harness.repository.getSettings())?.rememberedTagDeviceId, + ).toBeUndefined(); + expect(await harness.repository.getDevice(selectedId!)).toBeDefined(); + expect(store.getSnapshot().connectionState).toBe("idle"); + await store.dispose(); + }); + + test("uses the documented identity-aligned PANS-to-field conversion", () => { + expect( + pansPositionToFieldPoint({ + xMeters: -2, + yMeters: 4, + zMeters: 1, + quality: 20, + }), + ).toEqual({ xMeters: -2, yMeters: 4 }); + }); +}); + +async function createHarness( + options: { + remembered?: ManagedDevice; + streamStart?: jest.Mock, [StartPansPositionStreamOptions]>; + } = {}, +) { + const repository = new InMemoryPansManagerRepository(); + await repository.initialize(); + if (options.remembered) { + await repository.saveDevice(options.remembered); + const settings = await repository.getSettings(); + await repository.saveSettings({ + ...settings!, + rememberedTagDeviceId: options.remembered.id, + }); + } + let streamOptions: StartPansPositionStreamOptions | undefined; + const streamStart = options.streamStart ?? jest.fn(async () => undefined); + const runtime = { + repository, + discovery: { + getPermissionStatus: () => ({ bluetooth: "granted" }), + requestPermissions: async () => ({ bluetooth: "granted" }), + start: jest.fn(async () => undefined), + stop: jest.fn(async () => undefined), + subscribe: (listener: (items: DiscoveredDeviceSnapshot[]) => void) => { + listener([DISCOVERY]); + return { remove: jest.fn() }; + }, + subscribeErrors: () => ({ remove: jest.fn() }), + }, + sessions: { + addConnectionStateListener: () => ({ remove: jest.fn() }), + closeAll: jest.fn(async () => undefined), + }, + stream: { + start: jest.fn(async (next: StartPansPositionStreamOptions) => { + streamOptions = next; + await streamStart(next); + }), + stop: jest.fn(async () => undefined), + }, + configuration: {}, + diagnostics: {}, + close: jest.fn(async () => undefined), + } as unknown as MobilePansRuntime; + return { + repository, + runtime, + streamStart, + emitSample(sample: PansPositionStreamSample) { + streamOptions?.onSample(sample); + }, + }; +} + +function managedTag(): ManagedDevice { + return { + id: "remembered-tag", + transportDeviceId: DISCOVERY.transportDeviceId, + role: "tag", + createdAt: 1, + updatedAt: 1, + }; +} + +function positionSample( + receivedAt: number, + xMeters: number, + yMeters: number, + zMeters: number, +): PansPositionStreamSample { + return { + deviceId: "tag", + transportDeviceId: DISCOVERY.transportDeviceId, + receivedAt, + source: "notification", + position: { xMeters, yMeters, zMeters, quality: 40 }, + distances: [], + diagnostics: [], + decoderDiagnostics: [], + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/apps/mobile/src/pans/mobile-pans-context.tsx b/apps/mobile/src/pans/mobile-pans-context.tsx new file mode 100644 index 00000000..f89b5043 --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-context.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import { useSharedValue, type SharedValue } from "react-native-reanimated"; +import type { FieldPoint } from "@eight2five/mobile/field"; + +import { MobilePansStore, type MobilePansSnapshot } from "./mobile-pans-store"; + +interface MobilePansContextValue { + readonly store: MobilePansStore; + readonly positionValue: SharedValue; +} + +const MobilePansContext = React.createContext( + null, +); + +export function MobilePansProvider({ + children, + store: injectedStore, + appState = AppState, +}: { + readonly children: React.ReactNode; + readonly store?: MobilePansStore; + readonly appState?: { + readonly currentState: AppStateStatus | null; + addEventListener( + type: "change", + listener: (state: AppStateStatus) => void, + ): { remove(): void }; + }; +}) { + const [ownedStore] = React.useState(() => new MobilePansStore()); + const store = injectedStore ?? ownedStore; + const positionValue = useSharedValue(null); + + React.useEffect(() => { + store.attachPositionValue(positionValue); + store.setForeground(appState.currentState === "active"); + void store.initialize(); + const subscription = appState.addEventListener("change", (state) => { + store.setForeground(state === "active"); + }); + return () => { + subscription.remove(); + void store.dispose(); + }; + }, [appState, positionValue, store]); + + const value = React.useMemo( + () => ({ store, positionValue }), + [positionValue, store], + ); + return ( + + {children} + + ); +} + +export function useMobilePansStore(): MobilePansStore { + return useMobilePansContext().store; +} + +export function useMobilePansSnapshot(): MobilePansSnapshot { + const store = useMobilePansStore(); + return React.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); +} + +export function useFieldLivePosition() { + const context = useMobilePansContext(); + const snapshot = useMobilePansSnapshot(); + return React.useMemo( + () => ({ + state: snapshot.livePosition, + positionValue: context.positionValue, + }), + [context.positionValue, snapshot.livePosition], + ); +} + +function useMobilePansContext(): MobilePansContextValue { + const context = React.useContext(MobilePansContext); + if (!context) { + throw new Error( + "Mobile PANS hooks must be used inside MobilePansProvider.", + ); + } + return context; +} diff --git a/apps/mobile/src/pans/mobile-pans-runtime.ts b/apps/mobile/src/pans/mobile-pans-runtime.ts new file mode 100644 index 00000000..eae53333 --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-runtime.ts @@ -0,0 +1,60 @@ +import type { + PansConfigurationService, + PansDeviceSessionManager, + PansDiagnosticsService, + PansDiscoveryService, + PansManagerRepository, + PansPositionStreamService, +} from "@eight2five/mobile/pans-manager"; + +export interface MobilePansRuntime { + readonly repository: PansManagerRepository; + readonly discovery: PansDiscoveryService; + readonly sessions: PansDeviceSessionManager; + readonly stream: PansPositionStreamService; + readonly configuration: PansConfigurationService; + readonly diagnostics: PansDiagnosticsService; + close(): Promise; +} + +export type CreateMobilePansRuntime = () => Promise; + +export const createDefaultMobilePansRuntime: CreateMobilePansRuntime = + async () => { + // The native module is unavailable in Expo Go and registry-style tests. + // Keep it lazy so the app can surface initialization failure safely. + const manager = await import("@eight2five/mobile/pans-manager"); + const storage = await manager.openPansManagerRepository(); + try { + await storage.repository.initialize(); + const settings = manager.normalizePansManagerSettings( + await storage.repository.getSettings(), + ); + const discovery = new manager.PansDiscoveryService(undefined, { + staleAfterMs: settings.discoveryStaleAfterMs, + }); + const sessions = new manager.PansDeviceSessionManager( + undefined, + settings.connectionTimeoutMs, + ); + return { + repository: storage.repository, + discovery, + sessions, + stream: new manager.PansPositionStreamService(sessions), + configuration: new manager.PansConfigurationService( + sessions, + storage.repository, + ), + diagnostics: new manager.PansDiagnosticsService(sessions), + close: async () => { + await discovery.stop().catch(() => undefined); + await sessions.closeAll().catch(() => undefined); + await storage.close(); + }, + }; + } catch (error) { + await storage.close(); + throw error; + } + }; diff --git a/apps/mobile/src/pans/mobile-pans-store.ts b/apps/mobile/src/pans/mobile-pans-store.ts new file mode 100644 index 00000000..9f3f5d36 --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-store.ts @@ -0,0 +1,714 @@ +import { + deviceFromDiscovery, + normalizeManagerError, + normalizePansManagerSettings, + normalizeTransportDeviceId, + type DiscoveredDeviceSnapshot, + type ManagedDevice, + type ManagerError, + type PansConnectionStateEvent, + type PansManagerSettings, + type PansPosition, + type PansPositionStreamCounters, + type PansPositionStreamSample, +} from "@eight2five/mobile/pans-manager"; +import type { + FieldLivePositionState, + FieldPoint, +} from "@eight2five/mobile/field"; +import { formatMarchingCoordinate } from "@eight2five/mobile/field"; +import type { SharedValue } from "react-native-reanimated"; + +import { + createDefaultMobilePansRuntime, + type CreateMobilePansRuntime, + type MobilePansRuntime, +} from "./mobile-pans-runtime"; + +export type TagConnectionState = + | "idle" + | "scanning" + | "connecting" + | "connected" + | "reconnecting" + | "disconnected" + | "error"; + +export interface MobilePansSnapshot { + readonly initialization: "loading" | "ready" | "error"; + readonly connectionState: TagConnectionState; + readonly rememberedTag?: ManagedDevice; + readonly discoveries: readonly DiscoveredDeviceSnapshot[]; + readonly livePosition: FieldLivePositionState; + readonly rawPosition?: Readonly< + Pick + >; + readonly lastUpdateAt?: number; + readonly effectiveUpdateRateHz: number; + readonly counters?: Readonly; + readonly diagnosticMessages: readonly string[]; + readonly error?: ManagerError | Error; +} + +export interface MobilePansStoreOptions { + readonly createRuntime?: CreateMobilePansRuntime; + readonly now?: () => number; + readonly schedule?: typeof setTimeout; + readonly cancel?: typeof clearTimeout; + readonly reconnectDelaysMs?: readonly number[]; + readonly staleAfterMs?: number; + readonly discoveryTimeoutMs?: number; +} + +const EMPTY_DISCOVERIES: readonly DiscoveredDeviceSnapshot[] = Object.freeze( + [], +); +const EMPTY_MESSAGES: readonly string[] = Object.freeze([]); +const DEFAULT_RECONNECT_DELAYS = Object.freeze([500, 1_500, 3_000]); +const HUD_PUBLICATION_INTERVAL_MS = 100; + +const INITIAL_SNAPSHOT: MobilePansSnapshot = Object.freeze({ + initialization: "loading", + connectionState: "idle", + discoveries: EMPTY_DISCOVERIES, + livePosition: Object.freeze({ connectionState: "idle", isStale: false }), + effectiveUpdateRateHz: 0, + diagnosticMessages: EMPTY_MESSAGES, +}); + +/** + * Owns the one production PANS runtime, connection attempt, notification + * stream, reconnect loop, and low-rate React snapshot. + */ +export class MobilePansStore { + private snapshot: MobilePansSnapshot = INITIAL_SNAPSHOT; + private readonly listeners = new Set<() => void>(); + private readonly createRuntime: CreateMobilePansRuntime; + private readonly now: () => number; + private readonly schedule: typeof setTimeout; + private readonly cancel: typeof clearTimeout; + private readonly reconnectDelaysMs: readonly number[]; + private readonly staleAfterMs: number; + private readonly discoveryTimeoutMs: number; + private runtime?: MobilePansRuntime; + private positionValue?: SharedValue; + private lifecycleGeneration = 0; + private connectionGeneration = 0; + private foreground = true; + private wantsConnection = false; + private connectPromise?: Promise; + private reconnectTimer?: ReturnType; + private reconnectDelayResolve?: () => void; + private cancelPendingDiscovery?: () => void; + private staleTimer?: ReturnType; + private discoverySubscription?: { remove(): void }; + private discoveryErrorSubscription?: { remove(): void }; + private connectionSubscription?: { remove(): void }; + private settings?: PansManagerSettings; + private rememberedTag?: ManagedDevice; + private discoveries: readonly DiscoveredDeviceSnapshot[] = EMPTY_DISCOVERIES; + private lastHudPublicationAt = 0; + private lastHudKey?: string; + private sampleTimes: number[] = []; + + constructor(options: MobilePansStoreOptions = {}) { + this.createRuntime = + options.createRuntime ?? createDefaultMobilePansRuntime; + this.now = options.now ?? Date.now; + this.schedule = options.schedule ?? setTimeout; + this.cancel = options.cancel ?? clearTimeout; + this.reconnectDelaysMs = + options.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS; + this.staleAfterMs = options.staleAfterMs ?? 2_500; + this.discoveryTimeoutMs = options.discoveryTimeoutMs ?? 10_000; + } + + readonly getSnapshot = (): MobilePansSnapshot => this.snapshot; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + attachPositionValue(value: SharedValue): void { + this.positionValue = value; + value.value = this.snapshot.livePosition.isStale + ? null + : (this.snapshot.livePosition.position ?? null); + } + + async initialize(): Promise { + const generation = ++this.lifecycleGeneration; + this.publish(INITIAL_SNAPSHOT); + try { + const runtime = await this.createRuntime(); + if (generation !== this.lifecycleGeneration) { + await runtime.close(); + return; + } + this.runtime = runtime; + this.settings = normalizePansManagerSettings( + await runtime.repository.getSettings(), + ); + this.rememberedTag = this.settings.rememberedTagDeviceId + ? await runtime.repository.getDevice( + this.settings.rememberedTagDeviceId, + ) + : undefined; + if (!this.rememberedTag && this.settings.rememberedTagDeviceId) { + await this.saveRememberedTag(undefined); + } + this.installRuntimeListeners(runtime, generation); + this.wantsConnection = Boolean(this.rememberedTag); + this.publishState(this.rememberedTag ? "disconnected" : "idle", { + initialization: "ready", + }); + if (this.wantsConnection && this.foreground) { + void this.startReconnectLoop(); + } + } catch (cause) { + if (generation !== this.lifecycleGeneration) return; + this.publish({ + ...INITIAL_SNAPSHOT, + initialization: "error", + connectionState: "error", + livePosition: { connectionState: "error", isStale: false }, + error: normalizeManagerError(cause, { operation: "initialize" }), + }); + } + } + + async startDiscovery(): Promise { + const runtime = this.requireRuntime(); + this.publishState("scanning", { error: undefined }); + try { + const permission = runtime.discovery.getPermissionStatus(); + if (permission.bluetooth !== "granted") { + await runtime.discovery.requestPermissions(); + } + await runtime.discovery.start(); + } catch (cause) { + const error = normalizeManagerError(cause, { operation: "discover tag" }); + this.publishState("error", { error }); + throw error; + } + } + + async stopDiscovery(): Promise { + const runtime = this.runtime; + if (!runtime) return; + await runtime.discovery.stop(); + if (this.snapshot.connectionState === "scanning") { + this.publishState(this.rememberedTag ? "disconnected" : "idle"); + } + } + + async selectTag(transportDeviceId: string): Promise { + const runtime = this.requireRuntime(); + const discovery = this.discoveries.find( + (item) => item.transportDeviceId === transportDeviceId, + ); + if (!discovery) throw new Error("The selected tag is no longer available."); + const devices = await runtime.repository.listDevices(); + const normalizedTransport = normalizeTransportDeviceId(transportDeviceId); + const existing = devices.find( + (device) => + normalizeTransportDeviceId(device.transportDeviceId) === + normalizedTransport || + (discovery.macAddress && device.macAddress === discovery.macAddress), + ); + const tag = deviceFromDiscovery(discovery, existing, { + id: existing?.id ?? createLocalId("tag"), + now: this.now(), + }); + const saved = await runtime.repository.saveDevice({ ...tag, role: "tag" }); + this.rememberedTag = saved; + await this.saveRememberedTag(saved.id); + this.wantsConnection = true; + this.publishState("disconnected", { + rememberedTag: saved, + error: undefined, + }); + } + + async connect(): Promise { + if (this.snapshot.connectionState === "connected") return; + this.wantsConnection = true; + this.cancelReconnect(); + await this.connectOnce(false); + } + + async reconnect(): Promise { + this.wantsConnection = true; + this.cancelReconnect(); + await this.connectOnce(true); + } + + async disconnect(): Promise { + this.wantsConnection = false; + ++this.connectionGeneration; + this.cancelReconnect(); + this.cancelPendingDiscovery?.(); + this.cancelPendingDiscovery = undefined; + this.cancelStaleTimer(); + this.clearLiveMarker(); + const runtime = this.runtime; + if (runtime) { + await Promise.allSettled([ + runtime.stream.stop(), + runtime.discovery.stop(), + ]); + } + this.publishState("disconnected", { + livePosition: staleLivePosition( + this.snapshot.livePosition, + "disconnected", + ), + error: undefined, + }); + } + + async forgetTag(): Promise { + await this.disconnect(); + this.rememberedTag = undefined; + await this.saveRememberedTag(undefined); + this.sampleTimes = []; + this.publish({ + ...this.snapshot, + connectionState: "idle", + rememberedTag: undefined, + livePosition: { connectionState: "idle", isStale: false }, + rawPosition: undefined, + lastUpdateAt: undefined, + effectiveUpdateRateHz: 0, + error: undefined, + }); + } + + setForeground(foreground: boolean): void { + if (this.foreground === foreground) return; + this.foreground = foreground; + if (!foreground) { + this.cancelReconnect(); + this.cancelStaleTimer(); + this.clearLiveMarker(); + const runtime = this.runtime; + if (runtime) { + void Promise.allSettled([ + runtime.stream.stop(), + runtime.discovery.stop(), + ]); + } + if (this.wantsConnection) { + this.publishState("reconnecting", { + livePosition: staleLivePosition( + this.snapshot.livePosition, + "reconnecting", + ), + }); + } + return; + } + if (this.wantsConnection && this.rememberedTag) { + void this.startReconnectLoop(); + } + } + + async dispose(): Promise { + ++this.lifecycleGeneration; + ++this.connectionGeneration; + this.wantsConnection = false; + this.cancelReconnect(); + this.cancelPendingDiscovery?.(); + this.cancelPendingDiscovery = undefined; + this.cancelStaleTimer(); + this.removeRuntimeListeners(); + const runtime = this.runtime; + this.runtime = undefined; + if (runtime) { + await runtime.stream.stop().catch(() => undefined); + await runtime.close(); + } + } + + getRuntime(): MobilePansRuntime { + return this.requireRuntime(); + } + + private async connectOnce(reconnecting: boolean): Promise { + if (this.connectPromise) return await this.connectPromise; + const generation = ++this.connectionGeneration; + const operation = this.performConnect(generation, reconnecting); + const tracked = operation.finally(() => { + if (this.connectPromise === tracked) this.connectPromise = undefined; + }); + this.connectPromise = tracked; + return await this.connectPromise; + } + + private async performConnect( + generation: number, + reconnecting: boolean, + ): Promise { + const runtime = this.requireRuntime(); + const tag = this.rememberedTag; + if (!tag) throw new Error("Select a PANS tag before connecting."); + if (!this.foreground) return; + const state = reconnecting ? "reconnecting" : "connecting"; + this.publishState(state, { error: undefined }); + try { + const available = await this.ensureDiscovered(tag, generation); + if (!this.isConnectionCurrent(generation)) return; + this.publishState(state); + await runtime.stream.start({ + deviceId: tag.id, + transportDeviceId: available.transportDeviceId, + onSample: (sample) => this.receiveSample(sample, generation), + onDiagnostic: (message) => this.receiveDiagnostic(message, generation), + onCounters: (counters) => { + if (this.isConnectionCurrent(generation)) + this.publish({ ...this.snapshot, counters }); + }, + }); + if (!this.isConnectionCurrent(generation)) { + await runtime.stream.stop(); + return; + } + await runtime.discovery.stop().catch(() => undefined); + this.publishState("connected", { + livePosition: { + ...this.snapshot.livePosition, + connectionState: "connected", + }, + error: undefined, + }); + } catch (cause) { + if (!this.isConnectionCurrent(generation)) return; + const error = normalizeManagerError(cause, { + deviceId: tag.id, + operation: reconnecting ? "reconnect tag" : "connect tag", + }); + this.clearLiveMarker(); + this.publishState("error", { + livePosition: staleLivePosition( + this.snapshot.livePosition, + "error", + error.message, + ), + error, + }); + throw error; + } + } + + private async ensureDiscovered( + tag: ManagedDevice, + generation: number, + ): Promise { + const existing = findDiscovery(this.discoveries, tag.transportDeviceId); + if (existing && !existing.stale) return existing; + const runtime = this.requireRuntime(); + this.publishState("scanning"); + const permission = runtime.discovery.getPermissionStatus(); + if (permission.bluetooth !== "granted") { + await runtime.discovery.requestPermissions(); + } + await runtime.discovery.start(); + return await new Promise((resolve, reject) => { + let settled = false; + let subscription: { remove(): void } | undefined; + const finish = (action: () => void) => { + if (settled) return; + settled = true; + this.cancel(timer); + subscription?.remove(); + if (this.cancelPendingDiscovery === cancelWait) { + this.cancelPendingDiscovery = undefined; + } + action(); + }; + const cancelWait = () => + finish(() => + reject(new Error("The connection attempt was cancelled.")), + ); + const timer = this.schedule(() => { + finish(() => + reject(new Error("The remembered PANS tag was not found nearby.")), + ); + }, this.discoveryTimeoutMs); + this.cancelPendingDiscovery = cancelWait; + subscription = runtime.discovery.subscribe((items) => { + if (!this.isConnectionCurrent(generation)) { + cancelWait(); + return; + } + const match = findDiscovery(items, tag.transportDeviceId); + if (!match || match.stale) return; + finish(() => resolve(match)); + }); + if (settled) subscription.remove(); + }); + } + + private async startReconnectLoop(): Promise { + if (!this.wantsConnection || !this.foreground || !this.rememberedTag) + return; + this.cancelReconnect(); + for ( + let attempt = 0; + attempt <= this.reconnectDelaysMs.length; + attempt += 1 + ) { + if (!this.wantsConnection || !this.foreground) return; + if (attempt > 0) { + await new Promise((resolve) => { + this.reconnectDelayResolve = resolve; + this.reconnectTimer = this.schedule( + () => { + this.reconnectTimer = undefined; + this.reconnectDelayResolve = undefined; + resolve(); + }, + this.reconnectDelaysMs[attempt - 1], + ); + }); + } + try { + await this.connectOnce(true); + return; + } catch { + // A bounded final error is already published by connectOnce. + } + } + } + + private receiveSample( + sample: PansPositionStreamSample, + generation: number, + ): void { + if (!this.isConnectionCurrent(generation) || !sample.position) return; + // MVP networks use the documented identity-aligned PANS/field frame: + // +X Side 1→Side 2, +Y front→back, meters. Calibration is out of scope. + const fieldPoint = pansPositionToFieldPoint(sample.position); + if (this.positionValue) this.positionValue.value = fieldPoint; + const now = sample.receivedAt; + this.sampleTimes = this.sampleTimes.filter((time) => now - time <= 1_000); + this.sampleTimes.push(now); + this.scheduleStale(generation); + const hudKey = formatMarchingCoordinate(fieldPoint); + if ( + hudKey !== this.lastHudKey || + now - this.lastHudPublicationAt >= HUD_PUBLICATION_INTERVAL_MS + ) { + this.lastHudKey = hudKey; + this.lastHudPublicationAt = now; + this.publish({ + ...this.snapshot, + connectionState: "connected", + livePosition: { + connectionState: "connected", + position: fieldPoint, + receivedAt: now, + isStale: false, + }, + rawPosition: { + xMeters: sample.position.xMeters, + yMeters: sample.position.yMeters, + zMeters: sample.position.zMeters, + }, + lastUpdateAt: now, + effectiveUpdateRateHz: this.sampleTimes.length, + error: undefined, + }); + } + } + + private receiveDiagnostic(message: string, generation: number): void { + if (!this.isConnectionCurrent(generation)) return; + const diagnosticMessages = [ + ...this.snapshot.diagnosticMessages, + message, + ].slice(-8); + this.publish({ ...this.snapshot, diagnosticMessages }); + } + + private scheduleStale(generation: number): void { + this.cancelStaleTimer(); + this.staleTimer = this.schedule(() => { + this.staleTimer = undefined; + if (!this.isConnectionCurrent(generation)) return; + this.clearLiveMarker(); + this.publish({ + ...this.snapshot, + livePosition: staleLivePosition( + this.snapshot.livePosition, + this.snapshot.connectionState === "connected" + ? "connected" + : "reconnecting", + ), + }); + }, this.staleAfterMs); + } + + private installRuntimeListeners( + runtime: MobilePansRuntime, + generation: number, + ): void { + this.discoverySubscription = runtime.discovery.subscribe((discoveries) => { + if (!this.isLifecycleCurrent(generation)) return; + this.discoveries = discoveries; + this.publish({ ...this.snapshot, discoveries }); + }); + this.discoveryErrorSubscription = runtime.discovery.subscribeErrors( + (error) => { + if (this.isLifecycleCurrent(generation)) + this.publish({ ...this.snapshot, error }); + }, + ); + this.connectionSubscription = runtime.sessions.addConnectionStateListener( + (event) => this.receiveConnectionEvent(event, generation), + ); + } + + private receiveConnectionEvent( + event: PansConnectionStateEvent, + generation: number, + ): void { + if ( + !this.isLifecycleCurrent(generation) || + !this.rememberedTag || + normalizeTransportDeviceId(event.deviceId) !== + normalizeTransportDeviceId(this.rememberedTag.transportDeviceId) || + event.state !== "disconnected" || + this.snapshot.connectionState !== "connected" + ) { + return; + } + this.clearLiveMarker(); + this.publishState(this.wantsConnection ? "reconnecting" : "disconnected", { + livePosition: staleLivePosition( + this.snapshot.livePosition, + this.wantsConnection ? "reconnecting" : "disconnected", + event.reason, + ), + }); + if (this.wantsConnection && this.foreground) void this.startReconnectLoop(); + } + + private async saveRememberedTag(deviceId: string | undefined): Promise { + const runtime = this.requireRuntime(); + this.settings = normalizePansManagerSettings({ + ...this.settings, + rememberedTagDeviceId: deviceId, + }); + if (!deviceId) delete this.settings.rememberedTagDeviceId; + await runtime.repository.saveSettings(this.settings); + } + + private publishState( + connectionState: TagConnectionState, + changes: Partial = {}, + ): void { + const fieldState = fieldConnectionState(connectionState); + this.publish({ + ...this.snapshot, + ...changes, + connectionState, + rememberedTag: changes.rememberedTag ?? this.rememberedTag, + discoveries: this.discoveries, + livePosition: + changes.livePosition ?? + ({ + ...this.snapshot.livePosition, + connectionState: fieldState, + } as const), + }); + } + + private publish(snapshot: MobilePansSnapshot): void { + this.snapshot = Object.freeze(snapshot); + for (const listener of this.listeners) listener(); + } + + private isLifecycleCurrent(generation: number): boolean { + return generation === this.lifecycleGeneration; + } + + private isConnectionCurrent(generation: number): boolean { + return generation === this.connectionGeneration && this.wantsConnection; + } + + private requireRuntime(): MobilePansRuntime { + if (!this.runtime || this.snapshot.initialization !== "ready") { + throw new Error("PANS services are not ready."); + } + return this.runtime; + } + + private cancelReconnect(): void { + if (this.reconnectTimer) this.cancel(this.reconnectTimer); + this.reconnectTimer = undefined; + const resolve = this.reconnectDelayResolve; + this.reconnectDelayResolve = undefined; + resolve?.(); + } + + private cancelStaleTimer(): void { + if (this.staleTimer) this.cancel(this.staleTimer); + this.staleTimer = undefined; + } + + private clearLiveMarker(): void { + if (this.positionValue) this.positionValue.value = null; + } + + private removeRuntimeListeners(): void { + this.discoverySubscription?.remove(); + this.discoveryErrorSubscription?.remove(); + this.connectionSubscription?.remove(); + this.discoverySubscription = undefined; + this.discoveryErrorSubscription = undefined; + this.connectionSubscription = undefined; + } +} + +export function pansPositionToFieldPoint(position: PansPosition): FieldPoint { + return { xMeters: position.xMeters, yMeters: position.yMeters }; +} + +function findDiscovery( + discoveries: readonly DiscoveredDeviceSnapshot[], + transportDeviceId: string, +): DiscoveredDeviceSnapshot | undefined { + const normalized = normalizeTransportDeviceId(transportDeviceId); + return discoveries.find( + (item) => normalizeTransportDeviceId(item.transportDeviceId) === normalized, + ); +} + +function fieldConnectionState( + state: TagConnectionState, +): FieldLivePositionState["connectionState"] { + if (state === "scanning") return "connecting"; + return state; +} + +function staleLivePosition( + live: FieldLivePositionState, + connectionState: FieldLivePositionState["connectionState"], + errorMessage?: string, +): FieldLivePositionState { + return { + ...live, + connectionState, + isStale: Boolean(live.position), + ...(errorMessage ? { errorMessage } : {}), + }; +} + +function createLocalId(prefix: string): string { + const uuid = globalThis.crypto?.randomUUID?.(); + return uuid + ? `${prefix}-${uuid}` + : `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/packages/mobile/src/pans-manager/PansDeviceSessionManager.ts b/packages/mobile/src/pans-manager/PansDeviceSessionManager.ts index 6bf43872..4f833acb 100644 --- a/packages/mobile/src/pans-manager/PansDeviceSessionManager.ts +++ b/packages/mobile/src/pans-manager/PansDeviceSessionManager.ts @@ -1,4 +1,5 @@ import { + addConnectionStateChangedListener, addLocationDataListener, connect, decodeLocationData, @@ -26,6 +27,7 @@ import { writePersistedPosition, } from "expo-pans-ble-api"; import type { + ConnectionStateChangeEvent, PansAnchorList, PansCharacteristicNotificationEvent, PansClusterInfo, @@ -69,6 +71,9 @@ export interface PansNativeGateway { addLocationDataListener( listener: (event: PansLocationNotification) => void, ): PansLocationSubscription; + addConnectionStateChangedListener?( + listener: (event: PansConnectionStateEvent) => void, + ): PansLocationSubscription; decodeLocationData(payload: number[]): PansLocationData; requestMtu?(deviceId: string, mtu: number): Promise; writePersistedPosition( @@ -93,6 +98,9 @@ export interface PansLocationSubscription { remove(): void; } +/** Manager-safe connection event used by app-level session owners. */ +export type PansConnectionStateEvent = ConnectionStateChangeEvent; + export const defaultPansNativeGateway: PansNativeGateway = { connect, disconnect, @@ -124,6 +132,7 @@ export const defaultPansNativeGateway: PansNativeGateway = { payloadLength: event.payloadLength, }), ), + addConnectionStateChangedListener, decodeLocationData, requestMtu: async (deviceId, mtu) => getCapabilities().supportsMtuRequest @@ -290,6 +299,16 @@ export class PansDeviceSessionManager { if (failure) throw normalizeManagerError(failure.reason); } + addConnectionStateListener( + listener: (event: PansConnectionStateEvent) => void, + ): PansLocationSubscription { + return ( + this.gateway.addConnectionStateChangedListener?.(listener) ?? { + remove() {}, + } + ); + } + private async acquire(deviceId: string, timeoutMs?: number): Promise { if (!deviceId.trim()) { throw new ManagerError("INVALID_CONFIGURATION", "Device ID is required."); diff --git a/packages/mobile/src/pans-manager/__tests__/session.test.ts b/packages/mobile/src/pans-manager/__tests__/session.test.ts index fb827a45..530415e5 100644 --- a/packages/mobile/src/pans-manager/__tests__/session.test.ts +++ b/packages/mobile/src/pans-manager/__tests__/session.test.ts @@ -35,6 +35,21 @@ function gateway( } describe("PansDeviceSessionManager", () => { + test("exposes one removable native connection-state subscription", () => { + const remove = jest.fn(); + const addConnectionStateChangedListener = jest.fn(() => ({ remove })); + const manager = new PansDeviceSessionManager( + gateway({ addConnectionStateChangedListener }), + ); + const listener = jest.fn(); + + const subscription = manager.addConnectionStateListener(listener); + + expect(addConnectionStateChangedListener).toHaveBeenCalledWith(listener); + subscription.remove(); + expect(remove).toHaveBeenCalledTimes(1); + }); + test("reserves exactly one live session while its connection opens", async () => { let resolve!: (connected: boolean) => void; const connect = jest.fn( diff --git a/packages/mobile/src/pans-manager/__tests__/settings.test.ts b/packages/mobile/src/pans-manager/__tests__/settings.test.ts index 3ee2cf77..8ad74f57 100644 --- a/packages/mobile/src/pans-manager/__tests__/settings.test.ts +++ b/packages/mobile/src/pans-manager/__tests__/settings.test.ts @@ -20,6 +20,15 @@ describe("PANS manager settings compatibility", () => { expect(network).not.toHaveProperty("scanDurationMs"); }); + test("preserves only a non-empty remembered tag identity", () => { + expect( + normalizePansManagerSettings({ rememberedTagDeviceId: "tag-1" }), + ).toMatchObject({ rememberedTagDeviceId: "tag-1" }); + expect( + normalizePansManagerSettings({ rememberedTagDeviceId: "" }), + ).not.toHaveProperty("rememberedTagDeviceId"); + }); + test("defaults and validates map display settings for older records", () => { expect( normalizeManagedNetworkSettings({ diff --git a/packages/mobile/src/pans-manager/types.ts b/packages/mobile/src/pans-manager/types.ts index f9255fc5..bfee6fda 100644 --- a/packages/mobile/src/pans-manager/types.ts +++ b/packages/mobile/src/pans-manager/types.ts @@ -396,6 +396,8 @@ export interface PansManagerSettings { connectionTimeoutMs: number; positionLogMemoryCap: number; positionLogFlushSize: number; + /** Stable local device identity selected by the performer app. */ + rememberedTagDeviceId?: string; } export const DEFAULT_PANS_MANAGER_SETTINGS: PansManagerSettings = { @@ -411,6 +413,13 @@ export function normalizePansManagerSettings( const compatible = { ...(settings ?? {}) } as Partial & Record; delete compatible.discoveryScanDurationMs; + if ( + compatible.rememberedTagDeviceId !== undefined && + (typeof compatible.rememberedTagDeviceId !== "string" || + !compatible.rememberedTagDeviceId.trim()) + ) { + delete compatible.rememberedTagDeviceId; + } return { ...DEFAULT_PANS_MANAGER_SETTINGS, ...compatible }; } From e1d2adc5408e0e770913bd7211f9cf03c85ca8ab Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sat, 1 Aug 2026 00:49:26 -0500 Subject: [PATCH 018/101] feat(settings): add developer mode and diagnostics --- .../settings/developer-confirmation.tsx | 9 +- apps/mobile/app/(tabs)/settings/developer.tsx | 9 +- .../settings/__tests__/developer-mode.test.ts | 59 ++++++ .../developer-confirmation-screen.tsx | 85 ++++++++ .../settings/developer-diagnostics.ts | 89 +++++++++ .../settings/developer-mode-actions.ts | 24 +++ .../settings/developer-settings-screen.tsx | 181 ++++++++++++++++++ apps/mobile/src/pans/mobile-pans-store.ts | 49 +++++ 8 files changed, 491 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/features/settings/__tests__/developer-mode.test.ts create mode 100644 apps/mobile/src/features/settings/developer-confirmation-screen.tsx create mode 100644 apps/mobile/src/features/settings/developer-diagnostics.ts create mode 100644 apps/mobile/src/features/settings/developer-mode-actions.ts create mode 100644 apps/mobile/src/features/settings/developer-settings-screen.tsx diff --git a/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx b/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx index b15be7c7..567a201a 100644 --- a/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx +++ b/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx @@ -1,10 +1,5 @@ -import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; +import { DeveloperConfirmationScreen } from "../../../src/features/settings/developer-confirmation-screen"; export default function DeveloperConfirmationRoute() { - return ( - - ); + return ; } diff --git a/apps/mobile/app/(tabs)/settings/developer.tsx b/apps/mobile/app/(tabs)/settings/developer.tsx index 66d49165..ff80c9dd 100644 --- a/apps/mobile/app/(tabs)/settings/developer.tsx +++ b/apps/mobile/app/(tabs)/settings/developer.tsx @@ -1,10 +1,5 @@ -import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; +import { DeveloperSettingsScreen } from "../../../src/features/settings/developer-settings-screen"; export default function DeveloperSettingsRoute() { - return ( - - ); + return ; } diff --git a/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts new file mode 100644 index 00000000..1de8457d --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts @@ -0,0 +1,59 @@ +import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; + +import { buildDeveloperDiagnosticRows } from "../developer-diagnostics"; +import { + DEVELOPER_MODE_WARNING, + canUseDeveloperControls, + disableDeveloperMode, + enableDeveloperMode, +} from "../developer-mode-actions"; + +describe("Developer Mode", () => { + test("enables only through the explicit confirmation action", async () => { + const enabled = { ...DEFAULT_APP_SETTINGS, developerModeEnabled: true }; + const writer = { update: jest.fn(async () => enabled) }; + + await expect(enableDeveloperMode(writer)).resolves.toEqual(enabled); + + expect(writer.update).toHaveBeenCalledWith({ developerModeEnabled: true }); + expect(DEVELOPER_MODE_WARNING).toContain("modify PANS anchor positions"); + expect(DEVELOPER_MODE_WARNING).toContain("reported locations inaccurate"); + }); + + test("disabling hides controls without changing another preference", async () => { + const disabled = { ...DEFAULT_APP_SETTINGS, developerModeEnabled: false }; + const writer = { update: jest.fn(async () => disabled) }; + + await disableDeveloperMode(writer); + + expect(writer.update).toHaveBeenCalledWith({ developerModeEnabled: false }); + expect(canUseDeveloperControls(disabled)).toBe(false); + }); + + test("diagnostic presentation never includes PANS quality", () => { + const rows = buildDeveloperDiagnosticRows({ + initialization: "ready", + connectionState: "connected", + discoveries: [], + livePosition: { + connectionState: "connected", + isStale: false, + position: { xMeters: 1, yMeters: 2 }, + }, + rawPosition: { xMeters: 1, yMeters: 2, zMeters: 3, quality: 99 } as never, + lastUpdateAt: 1_700_000_000_000, + effectiveUpdateRateHz: 9.5, + diagnosticMessages: [], + knownAnchors: [], + }); + + expect(rows).toEqual( + expect.arrayContaining([ + { label: "Raw PANS X", value: "1.000 m" }, + { label: "Effective update rate", value: "9.5 Hz" }, + ]), + ); + expect(JSON.stringify(rows).toLowerCase()).not.toContain("quality"); + expect(JSON.stringify(rows)).not.toContain("99"); + }); +}); diff --git a/apps/mobile/src/features/settings/developer-confirmation-screen.tsx b/apps/mobile/src/features/settings/developer-confirmation-screen.tsx new file mode 100644 index 00000000..5296943a --- /dev/null +++ b/apps/mobile/src/features/settings/developer-confirmation-screen.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { Code2, TriangleAlert, X } from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing } from "@eight2five/ui/theme"; + +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { + DEVELOPER_MODE_WARNING, + enableDeveloperMode, +} from "./developer-mode-actions"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +export function DeveloperConfirmationScreen() { + const router = useRouter(); + const store = useAppSettingsStore(); + const { status, settings } = useAppSettingsSnapshot(); + const [enabling, setEnabling] = React.useState(false); + const [error, setError] = React.useState(); + + const enable = async () => { + if (enabling || settings.developerModeEnabled) return; + setEnabling(true); + setError(undefined); + try { + await enableDeveloperMode(store); + router.replace("/(tabs)/settings/developer"); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setEnabling(false); + } + }; + + return ( + + {error ? ( + {error.message} + ) : null} + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/settings/developer-diagnostics.ts b/apps/mobile/src/features/settings/developer-diagnostics.ts new file mode 100644 index 00000000..0b1f1e42 --- /dev/null +++ b/apps/mobile/src/features/settings/developer-diagnostics.ts @@ -0,0 +1,89 @@ +import type { + ManagedDevice, + PansDiagnosticsResult, +} from "@eight2five/mobile/pans-manager"; + +import type { MobilePansSnapshot } from "../../pans/mobile-pans-store"; + +export interface DeveloperDiagnosticRow { + readonly label: string; + readonly value: string; +} + +/** Produces the intentionally quality-free diagnostics shown in production. */ +export function buildDeveloperDiagnosticRows( + snapshot: MobilePansSnapshot, +): readonly DeveloperDiagnosticRow[] { + const diagnostics = snapshot.hardwareDiagnostics; + const position = snapshot.rawPosition; + return [ + { label: "Connection state", value: snapshot.connectionState }, + { + label: "Node ID", + value: + diagnostics?.deviceInfo?.nodeIdHex ?? + snapshot.rememberedTag?.nodeIdHex ?? + "Unavailable", + }, + { + label: "PAN ID", + value: + diagnostics?.panId === undefined + ? (snapshot.rememberedTag?.lastKnownConfig?.panId?.toString() ?? + "Unavailable") + : formatHex(diagnostics.panId, 4), + }, + { label: "Firmware version", value: firmwareVersion(diagnostics) }, + { label: "Raw PANS X", value: formatMeters(position?.xMeters) }, + { label: "Raw PANS Y", value: formatMeters(position?.yMeters) }, + { label: "Raw PANS Z", value: formatMeters(position?.zMeters) }, + { + label: "Last update", + value: snapshot.lastUpdateAt + ? new Date(snapshot.lastUpdateAt).toISOString() + : "Never", + }, + { + label: "Effective update rate", + value: `${snapshot.effectiveUpdateRateHz.toFixed(1)} Hz`, + }, + { + label: "Locally known anchors", + value: snapshot.knownAnchors.length.toString(), + }, + ...snapshot.knownAnchors.map(anchorDiagnosticRow), + ]; +} + +function anchorDiagnosticRow( + anchor: ManagedDevice, + index: number, +): DeveloperDiagnosticRow { + const position = + anchor.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig.position + : undefined; + return { + label: `Cached anchor ${anchor.nodeIdHex ?? anchor.label ?? index + 1}`, + value: position + ? `${position.xMeters.toFixed(3)}, ${position.yMeters.toFixed(3)}, ${position.zMeters.toFixed(3)} m` + : "Position unavailable", + }; +} + +function firmwareVersion( + diagnostics: PansDiagnosticsResult | undefined, +): string { + if (!diagnostics?.deviceInfo) return "Unavailable"; + return diagnostics.operationMode.selectedFirmware === 2 + ? diagnostics.deviceInfo.firmware2Version.toString() + : diagnostics.deviceInfo.firmware1Version.toString(); +} + +function formatMeters(value: number | undefined): string { + return value === undefined ? "Unavailable" : `${value.toFixed(3)} m`; +} + +function formatHex(value: number, width: number): string { + return `0x${value.toString(16).toUpperCase().padStart(width, "0")}`; +} diff --git a/apps/mobile/src/features/settings/developer-mode-actions.ts b/apps/mobile/src/features/settings/developer-mode-actions.ts new file mode 100644 index 00000000..8a907a12 --- /dev/null +++ b/apps/mobile/src/features/settings/developer-mode-actions.ts @@ -0,0 +1,24 @@ +import type { AppSettings } from "@eight2five/mobile/settings"; + +export interface DeveloperModeWriter { + update(partial: { developerModeEnabled: boolean }): Promise; +} + +export const DEVELOPER_MODE_WARNING = + "Developer controls can modify PANS anchor positions. Incorrect anchor positions can make reported locations inaccurate. These controls are intended for advanced configuration."; + +export async function enableDeveloperMode( + writer: DeveloperModeWriter, +): Promise { + return await writer.update({ developerModeEnabled: true }); +} + +export async function disableDeveloperMode( + writer: DeveloperModeWriter, +): Promise { + return await writer.update({ developerModeEnabled: false }); +} + +export function canUseDeveloperControls(settings: AppSettings): boolean { + return settings.developerModeEnabled; +} diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx new file mode 100644 index 00000000..d885d912 --- /dev/null +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -0,0 +1,181 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { + Activity, + Code2, + Database, + RefreshCw, + Radio, +} from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { buildDeveloperDiagnosticRows } from "./developer-diagnostics"; +import { disableDeveloperMode } from "./developer-mode-actions"; +import { + SettingsMessage, + SettingsNavigationRow, + SettingsScreenContainer, + SettingsSection, + SettingsSwitchRow, + SettingsValueRow, +} from "./settings-components"; + +export function DeveloperSettingsScreen() { + const router = useRouter(); + const theme = useEight2FiveTheme(); + const settingsStore = useAppSettingsStore(); + const pansStore = useMobilePansStore(); + const { status, settings, error: settingsError } = useAppSettingsSnapshot(); + const pans = useMobilePansSnapshot(); + const [refreshing, setRefreshing] = React.useState(false); + const [operationError, setOperationError] = React.useState(); + const rows = React.useMemo(() => buildDeveloperDiagnosticRows(pans), [pans]); + + const disable = async () => { + setOperationError(undefined); + try { + await disableDeveloperMode(settingsStore); + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } + }; + + const refresh = async () => { + if (refreshing) return; + setRefreshing(true); + setOperationError(undefined); + try { + await pansStore.refreshDiagnostics(); + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } finally { + setRefreshing(false); + } + }; + + if (!settings.developerModeEnabled) { + return ( + + + + + router.push("/(tabs)/settings/developer-confirmation") + } + testID="developer-mode-confirmation-link" + /> + + + ); + } + + return ( + + {settingsError || operationError ? ( + + {(operationError ?? settingsError)?.message} + + ) : null} + + { + if (!enabled) void disable(); + }} + disabled={status !== "ready"} + testID="developer-mode-setting" + /> + + + + + + {rows.slice(1).map((row) => ( + + {row.label} + + {row.value} + + + ))} + + + + + + + + + + ); +} diff --git a/apps/mobile/src/pans/mobile-pans-store.ts b/apps/mobile/src/pans/mobile-pans-store.ts index 9f3f5d36..a8f56674 100644 --- a/apps/mobile/src/pans/mobile-pans-store.ts +++ b/apps/mobile/src/pans/mobile-pans-store.ts @@ -8,6 +8,7 @@ import { type ManagerError, type PansConnectionStateEvent, type PansManagerSettings, + type PansDiagnosticsResult, type PansPosition, type PansPositionStreamCounters, type PansPositionStreamSample, @@ -46,6 +47,8 @@ export interface MobilePansSnapshot { readonly lastUpdateAt?: number; readonly effectiveUpdateRateHz: number; readonly counters?: Readonly; + readonly hardwareDiagnostics?: PansDiagnosticsResult; + readonly knownAnchors: readonly ManagedDevice[]; readonly diagnosticMessages: readonly string[]; readonly error?: ManagerError | Error; } @@ -74,6 +77,7 @@ const INITIAL_SNAPSHOT: MobilePansSnapshot = Object.freeze({ livePosition: Object.freeze({ connectionState: "idle", isStale: false }), effectiveUpdateRateHz: 0, diagnosticMessages: EMPTY_MESSAGES, + knownAnchors: Object.freeze([]), }); /** @@ -155,6 +159,11 @@ export class MobilePansStore { this.settings.rememberedTagDeviceId, ) : undefined; + const devices = await runtime.repository.listDevices(); + const knownAnchors = devices.filter( + (device) => + device.role === "anchor" || device.lastKnownConfig?.role === "anchor", + ); if (!this.rememberedTag && this.settings.rememberedTagDeviceId) { await this.saveRememberedTag(undefined); } @@ -162,6 +171,7 @@ export class MobilePansStore { this.wantsConnection = Boolean(this.rememberedTag); this.publishState(this.rememberedTag ? "disconnected" : "idle", { initialization: "ready", + knownAnchors, }); if (this.wantsConnection && this.foreground) { void this.startReconnectLoop(); @@ -285,6 +295,45 @@ export class MobilePansStore { }); } + async refreshDiagnostics(): Promise { + const runtime = this.requireRuntime(); + const tag = this.rememberedTag; + if (!tag || this.snapshot.connectionState !== "connected") { + throw new Error( + "Connect the remembered PANS tag before refreshing diagnostics.", + ); + } + ++this.connectionGeneration; + this.cancelStaleTimer(); + this.clearLiveMarker(); + this.publishState("reconnecting", { + livePosition: staleLivePosition( + this.snapshot.livePosition, + "reconnecting", + ), + error: undefined, + }); + try { + await runtime.stream.stop(); + const hardwareDiagnostics = await runtime.diagnostics.inspect( + tag.id, + tag.transportDeviceId, + ); + this.publish({ ...this.snapshot, hardwareDiagnostics }); + if (this.wantsConnection && this.foreground) await this.connectOnce(true); + return hardwareDiagnostics; + } catch (cause) { + const error = normalizeManagerError(cause, { + deviceId: tag.id, + operation: "refresh diagnostics", + }); + this.publishState("error", { error }); + if (this.wantsConnection && this.foreground) + void this.startReconnectLoop(); + throw error; + } + } + setForeground(foreground: boolean): void { if (this.foreground === foreground) return; this.foreground = foreground; From 06d096fc499a84a11f4cb9f1115b9fbacc58c565 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sat, 1 Aug 2026 01:18:58 -0500 Subject: [PATCH 019/101] feat(pans): add anchor position editing --- .../app/(tabs)/settings/anchor/[anchorId].tsx | 9 +- apps/mobile/app/(tabs)/settings/anchors.tsx | 9 +- .../components/marching-coordinate-form.tsx | 42 +-- .../__tests__/anchor-editor-form.test.ts | 97 ++++++ .../features/settings/anchor-editor-form.ts | 169 ++++++++++ .../settings/anchor-editor-screen.tsx | 215 ++++++++++++ .../features/settings/anchor-list-screen.tsx | 140 ++++++++ .../settings/anchor-write-confirmation.ts | 16 + .../settings/developer-settings-screen.tsx | 11 + .../standard-anchor-position-form.tsx | 209 ++++++++++++ .../settings/use-anchor-editor-controller.ts | 164 +++++++++ .../settings/use-anchor-list-controller.ts | 46 +++ .../pans/__tests__/mobile-pans-store.test.ts | 87 ++++- apps/mobile/src/pans/mobile-pans-store.ts | 116 ++++++- .../field/__tests__/anchor-position.test.ts | 307 +++++++++++++++++ packages/mobile/src/field/anchor-position.ts | 316 ++++++++++++++++++ packages/mobile/src/field/index.ts | 1 + packages/mobile/src/field/types.ts | 21 ++ packages/mobile/src/index.ts | 1 + 19 files changed, 1941 insertions(+), 35 deletions(-) create mode 100644 apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts create mode 100644 apps/mobile/src/features/settings/anchor-editor-form.ts create mode 100644 apps/mobile/src/features/settings/anchor-editor-screen.tsx create mode 100644 apps/mobile/src/features/settings/anchor-list-screen.tsx create mode 100644 apps/mobile/src/features/settings/anchor-write-confirmation.ts create mode 100644 apps/mobile/src/features/settings/standard-anchor-position-form.tsx create mode 100644 apps/mobile/src/features/settings/use-anchor-editor-controller.ts create mode 100644 apps/mobile/src/features/settings/use-anchor-list-controller.ts create mode 100644 packages/mobile/src/field/__tests__/anchor-position.test.ts create mode 100644 packages/mobile/src/field/anchor-position.ts diff --git a/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx b/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx index bc9c16f4..25623d63 100644 --- a/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx +++ b/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx @@ -1,14 +1,9 @@ import { useLocalSearchParams } from "expo-router"; -import { PlaceholderScreen } from "../../../../src/features/placeholder-screen"; +import { AnchorEditorScreen } from "../../../../src/features/settings/anchor-editor-screen"; export default function AnchorRoute() { const { anchorId } = useLocalSearchParams<{ anchorId: string }>(); - return ( - - ); + return ; } diff --git a/apps/mobile/app/(tabs)/settings/anchors.tsx b/apps/mobile/app/(tabs)/settings/anchors.tsx index 1e322ecd..17cf5a26 100644 --- a/apps/mobile/app/(tabs)/settings/anchors.tsx +++ b/apps/mobile/app/(tabs)/settings/anchors.tsx @@ -1,10 +1,5 @@ -import { PlaceholderScreen } from "../../../src/features/placeholder-screen"; +import { AnchorListScreen } from "../../../src/features/settings/anchor-list-screen"; export default function AnchorsRoute() { - return ( - - ); + return ; } diff --git a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx index 0b0f28e5..f3da765a 100644 --- a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx +++ b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx @@ -69,11 +69,13 @@ const FRONT_BACK_RELATION_CHOICES = [ export function MarchingCoordinateForm({ draft, terminologySingular, + showDetails = true, disabled, onChange, }: { draft: MarchingCoordinateDraft; terminologySingular: string; + showDetails?: boolean; disabled: boolean; onChange(draft: MarchingCoordinateDraft): void; }) { @@ -87,25 +89,27 @@ export function MarchingCoordinateForm({ return ( - - update("label", value)} - /> - update("countsFromPrevious", value)} - /> - + {showDetails ? ( + + update("label", value)} + /> + update("countsFromPrevious", value)} + /> + + ) : null} { + test("reuses the marching page coordinate domain", () => { + const draft = createAnchorEditorDrafts(); + const result = validateMarchingAnchorDraft({ + ...draft.marching, + height: "6", + heightUnit: "feet", + }); + + expect(result.errors).toEqual({}); + expect(result.position).toMatchObject({ + xMeters: 45.72, + yMeters: 0, + zMeters: expect.closeTo(1.8288, 8), + }); + expect(formatAnchorCanonicalPreview(result.position)?.marching).toContain( + "On 50 yd ln", + ); + }); + + test("standard mode validates signed offsets and preserves canonical position", () => { + const result = validateStandardAnchorDraft({ + reference: "center-field", + unit: "feet", + sideToSideOffset: "3", + frontToBackOffset: "-6", + height: "8", + }); + expect(result.errors).toEqual({}); + const converted = standardDraftFromPosition( + result.position!, + "center-field", + "feet", + ); + expect(converted).toEqual({ + reference: "center-field", + unit: "feet", + sideToSideOffset: "3", + frontToBackOffset: "-6", + height: "8", + }); + }); + + test("changing marching height units preserves canonical height", () => { + const draft = { ...createAnchorEditorDrafts().marching, height: "2" }; + const feet = convertMarchingHeightUnit(draft, "feet"); + expect(feet.height).toBe("6.56168"); + expect(validateMarchingAnchorDraft(feet).position?.zMeters).toBeCloseTo(2); + }); + + test("rejects incomplete and unreasonable submissions", () => { + const marching = createAnchorEditorDrafts().marching; + expect( + validateMarchingAnchorDraft({ ...marching, height: "" }).errors, + ).toHaveProperty("height"); + expect( + validateStandardAnchorDraft({ + reference: "side-1-front-corner", + unit: "meters", + sideToSideOffset: "-1", + frontToBackOffset: "0", + height: "1", + }).errors, + ).toHaveProperty("position"); + }); + + test("requires explicit confirmation before invoking one hardware action", () => { + const alert = jest + .spyOn(Alert, "alert") + .mockImplementation(() => undefined); + const write = jest.fn(); + + confirmAnchorPositionWrite({ xMeters: 1, yMeters: 2, zMeters: 3 }, write); + + expect(write).not.toHaveBeenCalled(); + const buttons = alert.mock.calls[0][2]; + expect(buttons?.map((button) => button.text)).toEqual([ + "Cancel", + "Write Position", + ]); + buttons?.[1].onPress?.(); + expect(write).toHaveBeenCalledTimes(1); + alert.mockRestore(); + }); +}); diff --git a/apps/mobile/src/features/settings/anchor-editor-form.ts b/apps/mobile/src/features/settings/anchor-editor-form.ts new file mode 100644 index 00000000..cc9435fc --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-editor-form.ts @@ -0,0 +1,169 @@ +import { + ANCHOR_POSITION_REFERENCE_LABELS, + ANCHOR_POSITION_REFERENCES, + anchorFieldPositionFromMarchingCoordinate, + anchorFieldPositionToStandard, + anchorPositionUnitsToMeters, + convertAnchorPositionUnits, + formatMarchingCoordinate, + parseAnchorPositionDraft, + type AnchorFieldPosition, + type AnchorPositionReference, + type AnchorPositionUnit, + type StandardAnchorPositionDraft, +} from "@eight2five/mobile/field"; + +import { + createDefaultPageDraft, + pageToDraft, + validatePageDraft, + type MarchingCoordinateDraft, +} from "../drill/page-form"; + +export type AnchorEditorMode = "marching" | "standard"; +export type MarchingHeightUnit = "meters" | "feet"; + +export interface MarchingAnchorDraft { + readonly coordinate: MarchingCoordinateDraft; + readonly height: string; + readonly heightUnit: MarchingHeightUnit; +} + +export interface AnchorDraftValidation { + readonly errors: Readonly>; + readonly position?: AnchorFieldPosition; +} + +export const ANCHOR_REFERENCE_CHOICES = ANCHOR_POSITION_REFERENCES.map( + (value) => ({ label: ANCHOR_POSITION_REFERENCE_LABELS[value], value }), +); + +export const ANCHOR_UNIT_CHOICES: readonly { + readonly label: string; + readonly value: AnchorPositionUnit; +}[] = [ + { label: "Meters", value: "meters" }, + { label: "Yards", value: "yards" }, + { label: "Feet", value: "feet" }, +]; + +export function createAnchorEditorDrafts(position?: AnchorFieldPosition): { + readonly marching: MarchingAnchorDraft; + readonly standard: StandardAnchorPositionDraft; +} { + const initial = position ?? { + xMeters: 45.72, + yMeters: 24.384, + zMeters: 2, + }; + const coordinate = position + ? pageToDraft({ + label: "Anchor", + countsFromPrevious: 0, + position, + }) + : createDefaultPageDraft({ ordinal: 0, suggestedLabel: "Anchor" }); + const standard = anchorFieldPositionToStandard( + initial, + "center-field", + "meters", + ); + return { + marching: { + coordinate, + height: String(initial.zMeters), + heightUnit: "meters", + }, + standard: { + reference: standard.reference, + unit: standard.unit, + sideToSideOffset: formatDraftNumber(standard.sideToSideOffset), + frontToBackOffset: formatDraftNumber(standard.frontToBackOffset), + height: formatDraftNumber(standard.height), + }, + }; +} + +export function validateMarchingAnchorDraft( + draft: MarchingAnchorDraft, +): AnchorDraftValidation { + const coordinate = validatePageDraft(draft.coordinate); + const errors: Record = { ...coordinate.errors }; + const height = Number(draft.height); + if (!draft.height.trim() || !Number.isFinite(height)) { + errors.height = "Enter a finite height."; + } else if (height < 0) { + errors.height = "Height cannot be negative."; + } + if (!coordinate.value || Object.keys(errors).length > 0) return { errors }; + try { + return { + errors, + position: anchorFieldPositionFromMarchingCoordinate( + coordinate.value.coordinate, + anchorPositionUnitsToMeters(height, draft.heightUnit), + ), + }; + } catch (cause) { + return { + errors: { + ...errors, + position: cause instanceof Error ? cause.message : String(cause), + }, + }; + } +} + +export function convertMarchingHeightUnit( + draft: MarchingAnchorDraft, + heightUnit: MarchingHeightUnit, +): MarchingAnchorDraft { + if (heightUnit === draft.heightUnit) return draft; + const value = Number(draft.height); + return { + ...draft, + heightUnit, + height: + draft.height.trim() && Number.isFinite(value) + ? formatDraftNumber( + convertAnchorPositionUnits(value, draft.heightUnit, heightUnit), + ) + : draft.height, + }; +} + +export function validateStandardAnchorDraft( + draft: StandardAnchorPositionDraft, +): AnchorDraftValidation { + const result = parseAnchorPositionDraft(draft); + return { errors: result.errors, position: result.value }; +} + +export function formatAnchorCanonicalPreview( + position: AnchorFieldPosition | undefined, +): { readonly marching: string; readonly meters: string } | undefined { + if (!position) return undefined; + return { + marching: formatMarchingCoordinate(position), + meters: `X ${position.xMeters.toFixed(3)} m · Y ${position.yMeters.toFixed(3)} m · Z ${position.zMeters.toFixed(3)} m`, + }; +} + +export function standardDraftFromPosition( + position: AnchorFieldPosition, + reference: AnchorPositionReference, + unit: AnchorPositionUnit, +): StandardAnchorPositionDraft { + const standard = anchorFieldPositionToStandard(position, reference, unit); + return { + reference, + unit, + sideToSideOffset: formatDraftNumber(standard.sideToSideOffset), + frontToBackOffset: formatDraftNumber(standard.frontToBackOffset), + height: formatDraftNumber(standard.height), + }; +} + +function formatDraftNumber(value: number): string { + return Number(value.toFixed(6)).toString(); +} diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx new file mode 100644 index 00000000..ec278fce --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx @@ -0,0 +1,215 @@ +import React from "react"; +import { Radio, Save, TriangleAlert } from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Card } from "@eight2five/ui/components/card"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { MarchingCoordinateForm } from "../drill/components/marching-coordinate-form"; +import { formatAnchorCanonicalPreview } from "./anchor-editor-form"; +import { confirmAnchorPositionWrite } from "./anchor-write-confirmation"; +import { + AnchorNumberInput, + StandardAnchorPositionForm, +} from "./standard-anchor-position-form"; +import { useAnchorEditorController } from "./use-anchor-editor-controller"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +export function AnchorEditorScreen({ + anchorId, +}: { + readonly anchorId: string; +}) { + const theme = useEight2FiveTheme(); + const controller = useAnchorEditorController(anchorId); + const preview = formatAnchorCanonicalPreview(controller.validation.position); + + if (!controller.developerModeEnabled) { + return ( + + + Enable Developer Mode before editing anchor positions. + + + ); + } + + return ( + + {controller.error ? ( + + {controller.error.message} + + ) : null} + {controller.saved ? ( + + Anchor position written. PANS positions are write-only, so the cache + records this successful unverified write. + + ) : null} + + + + + + + + + + {controller.mode === "marching" ? ( + + + controller.setMarchingDraft({ + ...controller.marchingDraft, + coordinate, + }) + } + /> + + + + {(["meters", "feet"] as const).map((unit) => ( + + ))} + + + controller.setMarchingDraft({ + ...controller.marchingDraft, + height, + }) + } + /> + + + + ) : ( + + + + + + )} + + + Canonical preview + + {preview?.marching ?? "Complete a valid in-bounds position."} + + {preview ? ( + + {preview.meters} + + ) : null} + + + {controller.connectionState !== "connected" ? ( + + + + A live tag connection is required before the confirmed hardware + write. + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/settings/anchor-list-screen.tsx b/apps/mobile/src/features/settings/anchor-list-screen.tsx new file mode 100644 index 00000000..86079ce8 --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-list-screen.tsx @@ -0,0 +1,140 @@ +import { useRouter } from "expo-router"; +import { Database, Pencil, RefreshCw, Triangle } from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { useAnchorListController } from "./use-anchor-list-controller"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +export function AnchorListScreen() { + const router = useRouter(); + const theme = useEight2FiveTheme(); + const controller = useAnchorListController(); + + if (!controller.developerModeEnabled) { + return ( + + + Enable Developer Mode before viewing or editing cached anchors. + + + ); + } + + return ( + + {controller.error ? ( + + {controller.error.message} + + ) : null} + + + + + + + + + {controller.anchors.length === 0 ? ( + + + + No anchors are cached. Discover the deployment with the tag to + cache nearby anchors. + + + ) : ( + controller.anchors.map((anchor) => { + const config = + anchor.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig + : undefined; + const position = config?.position; + return ( + + router.push({ + pathname: "/(tabs)/settings/anchor/[anchorId]", + params: { anchorId: anchor.id }, + }) + } + > + + + + + {anchor.nodeIdHex ?? anchor.label ?? anchor.id} + + + Initiator:{" "} + {config + ? config.initiatorEnabled + ? "Yes" + : "No" + : "Unknown"} + + + {position + ? `${position.xMeters.toFixed(3)}, ${position.yMeters.toFixed(3)}, ${position.zMeters.toFixed(3)} m` + : "Coordinate not cached"} + + + {position + ? "Source: local PANS cache" + : "Status: not configured"} + + + + + + ); + }) + )} + + + ); +} diff --git a/apps/mobile/src/features/settings/anchor-write-confirmation.ts b/apps/mobile/src/features/settings/anchor-write-confirmation.ts new file mode 100644 index 00000000..2e91b421 --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-write-confirmation.ts @@ -0,0 +1,16 @@ +import { Alert } from "react-native"; +import type { AnchorFieldPosition } from "@eight2five/mobile/field"; + +export function confirmAnchorPositionWrite( + position: AnchorFieldPosition, + onConfirm: () => void, +): void { + Alert.alert( + "Write anchor position?", + `This will write X ${position.xMeters.toFixed(3)} m, Y ${position.yMeters.toFixed(3)} m, Z ${position.zMeters.toFixed(3)} m to PANS hardware. Incorrect positions can make reported locations inaccurate.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Write Position", onPress: onConfirm }, + ], + ); +} diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx index d885d912..f14d5c40 100644 --- a/apps/mobile/src/features/settings/developer-settings-screen.tsx +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -6,6 +6,7 @@ import { Database, RefreshCw, Radio, + Triangle, } from "lucide-react-native"; import { Button, @@ -176,6 +177,16 @@ export function DeveloperSettingsScreen() { value={pans.knownAnchors.length.toString()} /> + + + router.push("/(tabs)/settings/anchors")} + testID="cached-anchors-link" + /> + ); } diff --git a/apps/mobile/src/features/settings/standard-anchor-position-form.tsx b/apps/mobile/src/features/settings/standard-anchor-position-form.tsx new file mode 100644 index 00000000..4aff0089 --- /dev/null +++ b/apps/mobile/src/features/settings/standard-anchor-position-form.tsx @@ -0,0 +1,209 @@ +import { ChevronDown } from "lucide-react-native"; +import type { + AnchorPositionReference, + AnchorPositionUnit, + StandardAnchorPositionDraft, +} from "@eight2five/mobile/field"; +import { + FormControl, + FormControlError, + FormControlErrorText, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { + Select, + SelectBackdrop, + SelectContent, + SelectDragIndicator, + SelectDragIndicatorWrapper, + SelectIcon, + SelectInput, + SelectItem, + SelectPortal, + SelectTrigger, +} from "@eight2five/ui/components/select"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { + ANCHOR_REFERENCE_CHOICES, + ANCHOR_UNIT_CHOICES, +} from "./anchor-editor-form"; + +export function StandardAnchorPositionForm({ + draft, + errors, + disabled, + onChange, + onReferenceChange, + onUnitChange, +}: { + readonly draft: StandardAnchorPositionDraft; + readonly errors: Readonly>; + readonly disabled: boolean; + readonly onChange: (draft: StandardAnchorPositionDraft) => void; + readonly onReferenceChange: (reference: AnchorPositionReference) => void; + readonly onUnitChange: (unit: AnchorPositionUnit) => void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + + onChange({ ...draft, sideToSideOffset }) + } + /> + + Negative side-to-side: toward Side 1{"\n"}Positive side-to-side: toward + Side 2 + + + onChange({ ...draft, frontToBackOffset }) + } + /> + + Negative front-to-back: toward front sideline{"\n"}Positive + front-to-back: toward back sideline + + onChange({ ...draft, height })} + /> + {errors.position ? ( + + {errors.position} + + ) : null} + + ); +} + +export function AnchorNumberInput({ + label, + value, + error, + helper, + disabled, + onChange, +}: { + readonly label: string; + readonly value: string; + readonly error?: string; + readonly helper?: string; + readonly disabled: boolean; + readonly onChange: (value: string) => void; +}) { + return ( + + + {label} + + + + + {helper ? ( + + {helper} + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} + +function AnchorSelect({ + label, + value, + choices, + disabled, + onChange, +}: { + readonly label: string; + readonly value: Value; + readonly choices: readonly { + readonly label: string; + readonly value: Value; + }[]; + readonly disabled: boolean; + readonly onChange: (value: Value) => void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + {label} + + + + ); +} diff --git a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts new file mode 100644 index 00000000..f4e026b5 --- /dev/null +++ b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts @@ -0,0 +1,164 @@ +import React from "react"; +import { useFocusEffect } from "expo-router"; +import type { + AnchorFieldPosition, + AnchorPositionUnit, + StandardAnchorPositionDraft, +} from "@eight2five/mobile/field"; +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { + createAnchorEditorDrafts, + convertMarchingHeightUnit, + standardDraftFromPosition, + validateMarchingAnchorDraft, + validateStandardAnchorDraft, + type AnchorEditorMode, + type MarchingAnchorDraft, +} from "./anchor-editor-form"; + +export function useAnchorEditorController(anchorId: string) { + const settings = useAppSettingsSnapshot(); + const pans = useMobilePansSnapshot(); + const pansStore = useMobilePansStore(); + const [anchor, setAnchor] = React.useState(); + const [mode, setModeState] = React.useState("marching"); + const [marchingDraft, setMarchingDraft] = React.useState( + () => createAnchorEditorDrafts().marching, + ); + const [standardDraft, setStandardDraft] = React.useState( + () => createAnchorEditorDrafts().standard, + ); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [saved, setSaved] = React.useState(false); + const [error, setError] = React.useState(); + + const load = React.useCallback(async () => { + if (pans.initialization !== "ready") return; + setLoading(true); + setError(undefined); + try { + const next = await pansStore.getRuntime().repository.getDevice(anchorId); + if ( + !next || + (next.role !== "anchor" && next.lastKnownConfig?.role !== "anchor") + ) { + throw new Error("The cached anchor could not be found."); + } + const position = + next.lastKnownConfig?.role === "anchor" + ? next.lastKnownConfig.position + : undefined; + const drafts = createAnchorEditorDrafts(position); + setAnchor(next); + setMarchingDraft(drafts.marching); + setStandardDraft(drafts.standard); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setLoading(false); + } + }, [anchorId, pans.initialization, pansStore]); + + useFocusEffect( + React.useCallback(() => { + void load(); + }, [load]), + ); + + const validation = + mode === "marching" + ? validateMarchingAnchorDraft(marchingDraft) + : validateStandardAnchorDraft(standardDraft); + + const setMode = (nextMode: AnchorEditorMode) => { + if (nextMode === mode) return; + const position = validation.position; + if (position) { + if (nextMode === "standard") { + setStandardDraft( + standardDraftFromPosition( + position, + standardDraft.reference, + standardDraft.unit, + ), + ); + } else { + setMarchingDraft(createAnchorEditorDrafts(position).marching); + } + } + setSaved(false); + setModeState(nextMode); + }; + + const updateStandardReference = ( + reference: StandardAnchorPositionDraft["reference"], + ) => { + const position = validateStandardAnchorDraft(standardDraft).position; + setStandardDraft( + position + ? standardDraftFromPosition(position, reference, standardDraft.unit) + : { ...standardDraft, reference }, + ); + }; + + const updateStandardUnit = (unit: AnchorPositionUnit) => { + const position = validateStandardAnchorDraft(standardDraft).position; + setStandardDraft( + position + ? standardDraftFromPosition(position, standardDraft.reference, unit) + : { ...standardDraft, unit }, + ); + }; + + const save = async (position: AnchorFieldPosition) => { + if (saving || !settings.settings.developerModeEnabled) return; + setSaving(true); + setSaved(false); + setError(undefined); + try { + await pansStore.writeAnchorPosition(anchorId, position); + setSaved(true); + await load(); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setSaving(false); + } + }; + + return { + developerModeEnabled: settings.settings.developerModeEnabled, + connectionState: pans.connectionState, + anchor, + mode, + marchingDraft, + standardDraft, + validation, + loading, + saving, + saved, + error, + setMode, + setMarchingDraft: (draft: MarchingAnchorDraft) => { + setSaved(false); + setMarchingDraft(draft); + }, + setStandardDraft: (draft: StandardAnchorPositionDraft) => { + setSaved(false); + setStandardDraft(draft); + }, + updateStandardReference, + updateStandardUnit, + updateMarchingHeightUnit: (heightUnit: MarchingAnchorDraft["heightUnit"]) => + setMarchingDraft((draft) => convertMarchingHeightUnit(draft, heightUnit)), + save, + reload: load, + } as const; +} diff --git a/apps/mobile/src/features/settings/use-anchor-list-controller.ts b/apps/mobile/src/features/settings/use-anchor-list-controller.ts new file mode 100644 index 00000000..ce148508 --- /dev/null +++ b/apps/mobile/src/features/settings/use-anchor-list-controller.ts @@ -0,0 +1,46 @@ +import React from "react"; +import { useFocusEffect } from "expo-router"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; + +export function useAnchorListController() { + const { settings } = useAppSettingsSnapshot(); + const pans = useMobilePansSnapshot(); + const store = useMobilePansStore(); + const [refreshing, setRefreshing] = React.useState(false); + const refreshingRef = React.useRef(false); + const [error, setError] = React.useState(); + + const refresh = React.useCallback(async () => { + if (pans.initialization !== "ready" || refreshingRef.current) return; + refreshingRef.current = true; + setRefreshing(true); + setError(undefined); + try { + await store.refreshCachedAnchors(); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + refreshingRef.current = false; + setRefreshing(false); + } + }, [pans.initialization, store]); + + useFocusEffect( + React.useCallback(() => { + void refresh(); + }, [refresh]), + ); + + return { + developerModeEnabled: settings.developerModeEnabled, + anchors: pans.knownAnchors, + refreshing, + error: error ?? pans.error, + refresh, + } as const; +} diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts index 70fa5746..f45e7baf 100644 --- a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts +++ b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts @@ -2,6 +2,7 @@ import { InMemoryPansManagerRepository } from "@eight2five/mobile/pans-manager"; import type { DiscoveredDeviceSnapshot, ManagedDevice, + PansPosition, PansPositionStreamSample, StartPansPositionStreamOptions, } from "@eight2five/mobile/pans-manager"; @@ -161,6 +162,41 @@ describe("MobilePansStore", () => { }), ).toEqual({ xMeters: -2, yMeters: 4 }); }); + + test("writes once with internal quality 100 and caches only successful writes", async () => { + const harness = await createHarness(); + await harness.repository.saveDevice(managedAnchor("anchor-1")); + await harness.repository.saveDevice(managedAnchor("anchor-2")); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + + const position = { xMeters: 10, yMeters: 20, zMeters: 2 }; + await Promise.all([ + store.writeAnchorPosition("anchor-1", position), + store.writeAnchorPosition("anchor-1", position), + ]); + + expect(harness.configurationApply).toHaveBeenCalledTimes(1); + expect(harness.configurationApply).toHaveBeenCalledWith("anchor-1", { + position: { ...position, quality: 100 }, + }); + expect(await harness.repository.getDevice("anchor-1")).toMatchObject({ + lastKnownConfig: { position: { ...position, quality: 100 } }, + }); + + harness.configurationApply.mockRejectedValueOnce(new Error("write failed")); + await expect( + store.writeAnchorPosition("anchor-2", position), + ).rejects.toBeDefined(); + expect( + (await harness.repository.getDevice("anchor-2"))?.lastKnownConfig, + ).not.toHaveProperty("position"); + await store.dispose(); + }); }); async function createHarness( @@ -181,6 +217,33 @@ async function createHarness( } let streamOptions: StartPansPositionStreamOptions | undefined; const streamStart = options.streamStart ?? jest.fn(async () => undefined); + const configurationApply = jest.fn( + async (deviceId: string, changes: { position: PansPosition }) => { + const device = (await repository.getDevice(deviceId))!; + await repository.saveDevice({ + ...device, + lastKnownConfig: { + ...(device.lastKnownConfig?.role === "anchor" + ? device.lastKnownConfig + : anchorConfig()), + position: changes.position, + }, + }); + return { + deviceId, + transportDeviceId: device.transportDeviceId, + outcome: "partial" as const, + writes: [ + { + field: "position", + status: "written-unverified" as const, + requested: changes.position, + }, + ], + warnings: [], + }; + }, + ); const runtime = { repository, discovery: { @@ -205,7 +268,7 @@ async function createHarness( }), stop: jest.fn(async () => undefined), }, - configuration: {}, + configuration: { applyConfigurationDiff: configurationApply }, diagnostics: {}, close: jest.fn(async () => undefined), } as unknown as MobilePansRuntime; @@ -213,12 +276,34 @@ async function createHarness( repository, runtime, streamStart, + configurationApply, emitSample(sample: PansPositionStreamSample) { streamOptions?.onSample(sample); }, }; } +function managedAnchor(id: string): ManagedDevice { + return { + id, + transportDeviceId: `transport-${id}`, + role: "anchor", + lastKnownConfig: anchorConfig(), + createdAt: 1, + updatedAt: 1, + }; +} + +function anchorConfig() { + return { + role: "anchor" as const, + uwbMode: "active" as const, + ledEnabled: true, + firmwareUpdateEnabled: false, + initiatorEnabled: false, + }; +} + function managedTag(): ManagedDevice { return { id: "remembered-tag", diff --git a/apps/mobile/src/pans/mobile-pans-store.ts b/apps/mobile/src/pans/mobile-pans-store.ts index a8f56674..5d0749d4 100644 --- a/apps/mobile/src/pans/mobile-pans-store.ts +++ b/apps/mobile/src/pans/mobile-pans-store.ts @@ -5,7 +5,7 @@ import { normalizeTransportDeviceId, type DiscoveredDeviceSnapshot, type ManagedDevice, - type ManagerError, + ManagerError, type PansConnectionStateEvent, type PansManagerSettings, type PansDiagnosticsResult, @@ -14,6 +14,7 @@ import { type PansPositionStreamSample, } from "@eight2five/mobile/pans-manager"; import type { + AnchorFieldPosition, FieldLivePositionState, FieldPoint, } from "@eight2five/mobile/field"; @@ -114,6 +115,7 @@ export class MobilePansStore { private lastHudPublicationAt = 0; private lastHudKey?: string; private sampleTimes: number[] = []; + private anchorWritePromise?: Promise; constructor(options: MobilePansStoreOptions = {}) { this.createRuntime = @@ -232,9 +234,28 @@ export class MobilePansStore { now: this.now(), }); const saved = await runtime.repository.saveDevice({ ...tag, role: "tag" }); + for (const nearbyAnchor of this.discoveries.filter( + (item) => item.presence?.role === "anchor", + )) { + const existingAnchor = devices.find( + (device) => + normalizeTransportDeviceId(device.transportDeviceId) === + normalizeTransportDeviceId(nearbyAnchor.transportDeviceId), + ); + const anchor = deviceFromDiscovery(nearbyAnchor, existingAnchor, { + id: existingAnchor?.id ?? createLocalId("anchor"), + now: this.now(), + }); + await runtime.repository.saveDevice({ + ...anchor, + role: "anchor", + ...(saved.networkId ? { networkId: saved.networkId } : {}), + }); + } this.rememberedTag = saved; await this.saveRememberedTag(saved.id); this.wantsConnection = true; + await this.refreshCachedAnchors(); this.publishState("disconnected", { rememberedTag: saved, error: undefined, @@ -384,6 +405,99 @@ export class MobilePansStore { return this.requireRuntime(); } + async refreshCachedAnchors(): Promise { + const runtime = this.requireRuntime(); + const devices = await runtime.repository.listDevices(); + const knownAnchors = devices + .filter( + (device) => + device.role === "anchor" || device.lastKnownConfig?.role === "anchor", + ) + .sort((left, right) => + (left.nodeIdHex ?? left.label ?? left.id).localeCompare( + right.nodeIdHex ?? right.label ?? right.id, + ), + ); + this.publish({ ...this.snapshot, knownAnchors }); + return knownAnchors; + } + + async writeAnchorPosition( + anchorId: string, + position: AnchorFieldPosition, + ): Promise { + if (this.anchorWritePromise) return await this.anchorWritePromise; + const operation = this.performAnchorPositionWrite(anchorId, position); + const tracked = operation.finally(() => { + if (this.anchorWritePromise === tracked) + this.anchorWritePromise = undefined; + }); + this.anchorWritePromise = tracked; + return await tracked; + } + + private async performAnchorPositionWrite( + anchorId: string, + position: AnchorFieldPosition, + ): Promise { + const runtime = this.requireRuntime(); + if (!this.rememberedTag || this.snapshot.connectionState !== "connected") { + throw new Error( + "Connect the remembered PANS tag before writing an anchor position.", + ); + } + const anchor = await runtime.repository.getDevice(anchorId); + if ( + !anchor || + (anchor.role !== "anchor" && anchor.lastKnownConfig?.role !== "anchor") + ) { + throw new Error("The selected cached anchor does not exist."); + } + ++this.connectionGeneration; + this.cancelStaleTimer(); + this.clearLiveMarker(); + this.publishState("reconnecting", { + livePosition: staleLivePosition( + this.snapshot.livePosition, + "reconnecting", + ), + error: undefined, + }); + try { + await runtime.stream.stop(); + const result = await runtime.configuration.applyConfigurationDiff( + anchor.id, + { + position: { ...position, quality: 100 }, + }, + ); + const write = result.writes.find((item) => item.field === "position"); + if (result.error || write?.status !== "written-unverified") { + throw new ManagerError( + result.error?.code ?? "WRITE_FAILED", + result.error?.message ?? "The anchor rejected the position write.", + { deviceId: anchor.id, operation: "write anchor position" }, + ); + } + await this.refreshCachedAnchors(); + } catch (cause) { + const error = normalizeManagerError(cause, { + deviceId: anchor.id, + operation: "write anchor position", + }); + this.publishState("error", { error }); + throw error; + } finally { + if (this.wantsConnection && this.foreground) { + try { + await this.connectOnce(true); + } catch { + // The reconnect action publishes its normalized failure. + } + } + } + } + private async connectOnce(reconnecting: boolean): Promise { if (this.connectPromise) return await this.connectPromise; const generation = ++this.connectionGeneration; diff --git a/packages/mobile/src/field/__tests__/anchor-position.test.ts b/packages/mobile/src/field/__tests__/anchor-position.test.ts new file mode 100644 index 00000000..5b5592c2 --- /dev/null +++ b/packages/mobile/src/field/__tests__/anchor-position.test.ts @@ -0,0 +1,307 @@ +import { + ANCHOR_POSITION_REFERENCES, + ANCHOR_POSITION_REFERENCE_POINTS, + ANCHOR_POSITION_UNITS, + MAX_ANCHOR_HEIGHT_METERS, + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + anchorFieldPositionFromMarchingCoordinate, + anchorFieldPositionFromStandard, + anchorFieldPositionToStandard, + convertAnchorPositionUnits, + getAnchorPositionReferencePoint, + metersToAnchorPositionUnits, + parseAnchorPositionDraft, + standardStepsToMeters, + yardsToMeters, +} from "../index"; + +const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + +describe("shared anchor position domain", () => { + test("defines exactly the standard references and their field points", () => { + expect(ANCHOR_POSITION_REFERENCES).toEqual([ + "center-field", + "center-front-sideline", + "center-back-sideline", + "side-1-front-corner", + "side-1-back-corner", + "side-2-front-corner", + "side-2-back-corner", + "side-1-goal-line-center", + "side-2-goal-line-center", + "front-hash-center", + "back-hash-center", + ]); + expect(Object.keys(ANCHOR_POSITION_REFERENCE_POINTS)).toHaveLength(11); + expect( + ANCHOR_POSITION_REFERENCES.map((reference) => + getAnchorPositionReferencePoint(reference), + ), + ).toEqual([ + { xMeters: field.goalToGoalMeters / 2, yMeters: field.widthMeters / 2 }, + { xMeters: field.goalToGoalMeters / 2, yMeters: 0 }, + { + xMeters: field.goalToGoalMeters / 2, + yMeters: field.widthMeters, + }, + { xMeters: 0, yMeters: 0 }, + { xMeters: 0, yMeters: field.widthMeters }, + { xMeters: field.goalToGoalMeters, yMeters: 0 }, + { + xMeters: field.goalToGoalMeters, + yMeters: field.widthMeters, + }, + { xMeters: 0, yMeters: field.widthMeters / 2 }, + { + xMeters: field.goalToGoalMeters, + yMeters: field.widthMeters / 2, + }, + { + xMeters: field.goalToGoalMeters / 2, + yMeters: field.frontHashLine.coordinateMeters, + }, + { + xMeters: field.goalToGoalMeters / 2, + yMeters: field.backHashLine.coordinateMeters, + }, + ]); + expect(getAnchorPositionReferencePoint("center-field")).toEqual({ + xMeters: field.goalToGoalMeters / 2, + yMeters: field.widthMeters / 2, + }); + expect(getAnchorPositionReferencePoint("front-hash-center")).toEqual({ + xMeters: field.goalToGoalMeters / 2, + yMeters: field.frontHashLine.coordinateMeters, + }); + expect(getAnchorPositionReferencePoint("side-2-goal-line-center")).toEqual({ + xMeters: field.goalToGoalMeters, + yMeters: field.widthMeters / 2, + }); + }); + + test("converts all supported units through one canonical meter path", () => { + expect(ANCHOR_POSITION_UNITS).toEqual(["meters", "yards", "feet"]); + const meters = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 1.25, + frontToBackOffset: -2.5, + height: 3, + }); + const yards = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "yards", + sideToSideOffset: 1.25 / 0.9144, + frontToBackOffset: -2.5 / 0.9144, + height: 3 / 0.9144, + }); + const feet = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "feet", + sideToSideOffset: 1.25 / 0.3048, + frontToBackOffset: -2.5 / 0.3048, + height: 3 / 0.3048, + }); + expect(yards).toEqual({ + xMeters: expect.closeTo(meters.xMeters, 10), + yMeters: expect.closeTo(meters.yMeters, 10), + zMeters: expect.closeTo(meters.zMeters, 10), + }); + expect(feet).toEqual({ + xMeters: expect.closeTo(meters.xMeters, 10), + yMeters: expect.closeTo(meters.yMeters, 10), + zMeters: expect.closeTo(meters.zMeters, 10), + }); + expect(convertAnchorPositionUnits(10, "yards", "feet")).toBeCloseTo(30); + expect(metersToAnchorPositionUnits(1, "feet")).toBeCloseTo(1 / 0.3048); + }); + + test("applies signed offsets in the documented field directions", () => { + const center = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 0, + frontToBackOffset: 0, + height: 0, + }); + const shifted = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 2, + frontToBackOffset: -3, + height: 1, + }); + expect(shifted.xMeters).toBe(center.xMeters + 2); + expect(shifted.yMeters).toBe(center.yMeters - 3); + expect(shifted.zMeters).toBe(1); + + const side1 = anchorFieldPositionFromStandard({ + reference: "side-1-front-corner", + unit: "yards", + sideToSideOffset: 5, + frontToBackOffset: 5, + height: 1, + }); + expect(side1.xMeters).toBeCloseTo(yardsToMeters(5)); + expect(side1.yMeters).toBeCloseTo(yardsToMeters(5)); + + const inverse = anchorFieldPositionToStandard( + shifted, + "center-field", + "meters", + ); + expect(inverse).toEqual({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 2, + frontToBackOffset: -3, + height: 1, + }); + }); + + test("reuses marching conversion for horizontal coordinates and adds height", () => { + const coordinate = { + side: { + side: 2 as const, + yardLine: 40, + relation: "inside" as const, + offsetSteps: 1.5, + }, + frontBack: { + reference: "front-hash" as const, + relation: "behind" as const, + offsetSteps: 2.25, + }, + }; + const expected = anchorFieldPositionFromMarchingCoordinate(coordinate, 2.4); + expect(expected.xMeters).toBeCloseTo( + field.goalToGoalMeters - yardsToMeters(40) - standardStepsToMeters(1.5), + ); + expect(expected.yMeters).toBeCloseTo( + field.frontHashLine.coordinateMeters + standardStepsToMeters(2.25), + ); + expect(expected.zMeters).toBe(2.4); + }); + + test("parses drafts and rejects empty, non-finite, out-of-field, and excessive values", () => { + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "feet", + sideToSideOffset: "3", + frontToBackOffset: "-4", + height: "6", + }), + ).toEqual({ + errors: {}, + value: expect.objectContaining({ + zMeters: expect.closeTo(6 * 0.3048, 10), + }), + }); + + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "meters", + sideToSideOffset: "", + frontToBackOffset: "", + height: "", + }).errors, + ).toMatchObject({ + sideToSideOffset: expect.any(String), + frontToBackOffset: expect.any(String), + height: expect.any(String), + }); + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "meters", + sideToSideOffset: "NaN", + frontToBackOffset: "Infinity", + height: "", + }).errors, + ).toMatchObject({ + sideToSideOffset: expect.any(String), + frontToBackOffset: expect.any(String), + height: expect.any(String), + }); + expect( + parseAnchorPositionDraft({ + reference: "side-1-front-corner", + unit: "meters", + sideToSideOffset: "-0.01", + frontToBackOffset: "0", + height: "0", + }).errors.position, + ).toContain("standard field bounds"); + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "meters", + sideToSideOffset: "0", + frontToBackOffset: "0", + height: "-1", + }).errors.height, + ).toContain("negative"); + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "meters", + sideToSideOffset: "0", + frontToBackOffset: "0", + height: String(MAX_ANCHOR_HEIGHT_METERS + 1), + }).errors.position, + ).toContain("at most"); + }); + + test("throws instead of silently clamping invalid canonical and standard values", () => { + expect(() => + anchorFieldPositionFromStandard({ + reference: "side-1-front-corner", + unit: "meters", + sideToSideOffset: -1, + frontToBackOffset: 0, + height: 0, + }), + ).toThrow("standard field bounds"); + expect(() => + anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 0, + frontToBackOffset: 0, + height: -0.1, + }), + ).toThrow("negative"); + expect(() => + anchorFieldPositionFromMarchingCoordinate( + { + side: { + side: 1, + yardLine: 0, + relation: "outside", + offsetSteps: 1, + }, + frontBack: { + reference: "front-sideline", + relation: "on", + offsetSteps: 0, + }, + }, + 1, + ), + ).toThrow("standard field bounds"); + }); + + test("does not add a quality field to canonical positions", () => { + const position = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 0, + frontToBackOffset: 0, + height: 1, + }); + expect(position).not.toHaveProperty("quality"); + expect(Object.keys(position)).toEqual(["xMeters", "yMeters", "zMeters"]); + }); +}); diff --git a/packages/mobile/src/field/anchor-position.ts b/packages/mobile/src/field/anchor-position.ts new file mode 100644 index 00000000..d0265261 --- /dev/null +++ b/packages/mobile/src/field/anchor-position.ts @@ -0,0 +1,316 @@ +import { + marchingCoordinateToFieldPoint, + type MarchingCoordinate, +} from "./marching"; +import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "./template"; +import type { AnchorFieldPosition, FieldPoint } from "./types"; +import { + feetToMeters, + metersToFeet, + metersToYards, + yardsToMeters, +} from "./units"; + +export const ANCHOR_POSITION_UNITS = ["meters", "yards", "feet"] as const; +export type AnchorPositionUnit = (typeof ANCHOR_POSITION_UNITS)[number]; + +export const ANCHOR_POSITION_REFERENCES = [ + "center-field", + "center-front-sideline", + "center-back-sideline", + "side-1-front-corner", + "side-1-back-corner", + "side-2-front-corner", + "side-2-back-corner", + "side-1-goal-line-center", + "side-2-goal-line-center", + "front-hash-center", + "back-hash-center", +] as const; +export type AnchorPositionReference = + (typeof ANCHOR_POSITION_REFERENCES)[number]; + +export const ANCHOR_POSITION_REFERENCE_LABELS: Readonly< + Record +> = Object.freeze({ + "center-field": "Center of field", + "center-front-sideline": "Center of front sideline", + "center-back-sideline": "Center of back sideline", + "side-1-front-corner": "Side 1/front corner", + "side-1-back-corner": "Side 1/back corner", + "side-2-front-corner": "Side 2/front corner", + "side-2-back-corner": "Side 2/back corner", + "side-1-goal-line-center": "Side 1 goal-line center", + "side-2-goal-line-center": "Side 2 goal-line center", + "front-hash-center": "Front-hash center", + "back-hash-center": "Back-hash center", +}); + +export interface StandardAnchorPositionInput { + readonly reference: AnchorPositionReference; + readonly unit: AnchorPositionUnit; + /** Negative is toward Side 1; positive is toward Side 2. */ + readonly sideToSideOffset: number; + /** Negative is toward the front sideline; positive is toward the back. */ + readonly frontToBackOffset: number; + readonly height: number; +} + +export interface StandardAnchorPositionDraft { + readonly reference: AnchorPositionReference; + readonly unit: AnchorPositionUnit; + readonly sideToSideOffset: string; + readonly frontToBackOffset: string; + readonly height: string; +} + +export type AnchorPositionDraftField = + | "sideToSideOffset" + | "frontToBackOffset" + | "height" + | "position"; +export type AnchorPositionDraftErrors = Partial< + Record +>; + +export interface ParsedAnchorPositionDraft { + readonly errors: AnchorPositionDraftErrors; + readonly value?: AnchorFieldPosition; +} + +export const MAX_ANCHOR_HEIGHT_METERS = 100; + +const template = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +const bounds = template.bounds; +const point = (xMeters: number, yMeters: number): FieldPoint => ({ + xMeters, + yMeters, +}); + +export const ANCHOR_POSITION_REFERENCE_POINTS: Readonly< + Record +> = Object.freeze({ + "center-field": point(bounds.maxXMeters / 2, bounds.maxYMeters / 2), + "center-front-sideline": point(bounds.maxXMeters / 2, bounds.minYMeters), + "center-back-sideline": point(bounds.maxXMeters / 2, bounds.maxYMeters), + "side-1-front-corner": point(bounds.minXMeters, bounds.minYMeters), + "side-1-back-corner": point(bounds.minXMeters, bounds.maxYMeters), + "side-2-front-corner": point(bounds.maxXMeters, bounds.minYMeters), + "side-2-back-corner": point(bounds.maxXMeters, bounds.maxYMeters), + "side-1-goal-line-center": point(bounds.minXMeters, bounds.maxYMeters / 2), + "side-2-goal-line-center": point(bounds.maxXMeters, bounds.maxYMeters / 2), + "front-hash-center": point( + bounds.maxXMeters / 2, + template.frontHashLine.coordinateMeters, + ), + "back-hash-center": point( + bounds.maxXMeters / 2, + template.backHashLine.coordinateMeters, + ), +}); + +export function getAnchorPositionReferencePoint( + reference: AnchorPositionReference, +): FieldPoint { + return ANCHOR_POSITION_REFERENCE_POINTS[reference]; +} + +export function anchorPositionUnitsToMeters( + value: number, + unit: AnchorPositionUnit, +): number { + assertFinite(value, "Anchor position value"); + if (unit === "yards") return yardsToMeters(value); + if (unit === "feet") return feetToMeters(value); + return value; +} + +export function metersToAnchorPositionUnits( + value: number, + unit: AnchorPositionUnit, +): number { + assertFinite(value, "Anchor position value"); + if (unit === "yards") return metersToYards(value); + if (unit === "feet") return metersToFeet(value); + return value; +} + +export function convertAnchorPositionUnits( + value: number, + from: AnchorPositionUnit, + to: AnchorPositionUnit, +): number { + return metersToAnchorPositionUnits( + anchorPositionUnitsToMeters(value, from), + to, + ); +} + +export function anchorFieldPositionFromStandard( + input: StandardAnchorPositionInput, +): AnchorFieldPosition { + const reference = getAnchorPositionReferencePoint(input.reference); + const position = { + xMeters: + reference.xMeters + + anchorPositionUnitsToMeters(input.sideToSideOffset, input.unit), + yMeters: + reference.yMeters + + anchorPositionUnitsToMeters(input.frontToBackOffset, input.unit), + zMeters: anchorPositionUnitsToMeters(input.height, input.unit), + }; + assertValidAnchorFieldPosition(position); + return position; +} + +export function anchorFieldPositionToStandard( + position: AnchorFieldPosition, + reference: AnchorPositionReference, + unit: AnchorPositionUnit, +): StandardAnchorPositionInput { + assertValidAnchorFieldPosition(position); + const origin = getAnchorPositionReferencePoint(reference); + return { + reference, + unit, + sideToSideOffset: metersToAnchorPositionUnits( + position.xMeters - origin.xMeters, + unit, + ), + frontToBackOffset: metersToAnchorPositionUnits( + position.yMeters - origin.yMeters, + unit, + ), + height: metersToAnchorPositionUnits(position.zMeters, unit), + }; +} + +export function anchorFieldPositionFromMarchingCoordinate( + coordinate: MarchingCoordinate, + heightMeters: number, +): AnchorFieldPosition { + const position = { + ...marchingCoordinateToFieldPoint(coordinate), + zMeters: heightMeters, + }; + assertValidAnchorFieldPosition(position); + return position; +} + +export function parseAnchorPositionDraft( + draft: StandardAnchorPositionDraft, +): ParsedAnchorPositionDraft { + const errors: AnchorPositionDraftErrors = {}; + const sideToSideOffset = parseFiniteDraftNumber( + draft.sideToSideOffset, + "Enter a finite side-to-side offset.", + errors, + "sideToSideOffset", + ); + const frontToBackOffset = parseFiniteDraftNumber( + draft.frontToBackOffset, + "Enter a finite front-to-back offset.", + errors, + "frontToBackOffset", + ); + const height = parseFiniteDraftNumber( + draft.height, + "Enter a finite height.", + errors, + "height", + ); + if (height !== undefined && height < 0) { + errors.height = "Height cannot be negative."; + } + if ( + sideToSideOffset === undefined || + frontToBackOffset === undefined || + height === undefined || + Object.keys(errors).length > 0 + ) { + return { errors }; + } + try { + return { + errors, + value: anchorFieldPositionFromStandard({ + reference: draft.reference, + unit: draft.unit, + sideToSideOffset, + frontToBackOffset, + height, + }), + }; + } catch (cause) { + return { + errors: { + ...errors, + position: cause instanceof Error ? cause.message : String(cause), + }, + }; + } +} + +export function validateAnchorFieldPosition( + position: unknown, +): AnchorPositionDraftErrors { + if (!position || typeof position !== "object") { + return { position: "Anchor field position is required." }; + } + const value = position as Partial; + if ( + !Number.isFinite(value.xMeters) || + !Number.isFinite(value.yMeters) || + !Number.isFinite(value.zMeters) + ) { + return { position: "Anchor coordinates must be finite." }; + } + if ( + value.xMeters! < bounds.minXMeters || + value.xMeters! > bounds.maxXMeters || + value.yMeters! < bounds.minYMeters || + value.yMeters! > bounds.maxYMeters + ) { + return { + position: "Anchor coordinates must be within the standard field bounds.", + }; + } + if (value.zMeters! < 0) { + return { position: "Anchor height cannot be negative." }; + } + if (value.zMeters! > MAX_ANCHOR_HEIGHT_METERS) { + return { + position: `Anchor height must be at most ${MAX_ANCHOR_HEIGHT_METERS} meters.`, + }; + } + return {}; +} + +export function assertValidAnchorFieldPosition( + position: unknown, +): asserts position is AnchorFieldPosition { + const message = validateAnchorFieldPosition(position).position; + if (message) throw new RangeError(message); +} + +function parseFiniteDraftNumber( + input: string, + message: string, + errors: AnchorPositionDraftErrors, + field: AnchorPositionDraftField, +): number | undefined { + if (!input.trim()) { + errors[field] = message; + return undefined; + } + const value = Number(input); + if (!Number.isFinite(value)) { + errors[field] = message; + return undefined; + } + return value; +} + +function assertFinite(value: number, label: string): void { + if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`); +} diff --git a/packages/mobile/src/field/index.ts b/packages/mobile/src/field/index.ts index 5e744457..eeb60a96 100644 --- a/packages/mobile/src/field/index.ts +++ b/packages/mobile/src/field/index.ts @@ -2,6 +2,7 @@ export * from "./types"; export * from "./units"; export * from "./template"; export * from "./marching"; +export * from "./anchor-position"; export * from "./guidance"; export * from "./live-position"; export * from "./camera/field-camera-types"; diff --git a/packages/mobile/src/field/types.ts b/packages/mobile/src/field/types.ts index f73eda29..45fc3cda 100644 --- a/packages/mobile/src/field/types.ts +++ b/packages/mobile/src/field/types.ts @@ -24,6 +24,16 @@ export interface FieldPosition extends FieldPoint { readonly zMeters?: number; } +/** + * The canonical three-dimensional position used for field anchors. + * + * Unlike the display-oriented FieldPosition type, an anchor position always + * has a height. Coordinates are stored in meters and z increases upward. + */ +export interface AnchorFieldPosition extends FieldPoint { + readonly zMeters: number; +} + /** The canonical origin and axis directions, useful to consumers drawing axes. */ export interface FieldCoordinateOrigin { readonly xMeters: 0; @@ -77,3 +87,14 @@ export function assertFiniteFieldPosition( throw new RangeError(`${name}.zMeters must be a finite number.`); } } + +/** Throws a clear error when a canonical anchor position is not finite. */ +export function assertFiniteAnchorFieldPosition( + position: AnchorFieldPosition, + name = "Anchor field position", +): void { + assertFiniteFieldPoint(position, name); + if (!Number.isFinite(position.zMeters)) { + throw new RangeError(`${name}.zMeters must be a finite number.`); + } +} diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 9badd5d2..605aa63b 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -31,6 +31,7 @@ export { } from "./field/units"; export * from "./field/template"; export * from "./field/marching"; +export * from "./field/anchor-position"; export * from "./field/guidance"; export * from "./field/live-position"; export * from "./field/camera/field-camera-types"; From 2a4b95a96c8435b7daca7003d631f2729273cf59 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sat, 1 Aug 2026 01:24:13 -0500 Subject: [PATCH 020/101] feat(field): add developer anchor overlays --- apps/mobile/app/(tabs)/field/index.tsx | 10 +- .../field-anchor-overlay-options.test.ts | 32 +++++++ .../field/field-anchor-overlay-options.ts | 16 ++++ .../src/features/field/field-screen.tsx | 12 ++- .../field/use-field-anchor-overlay.ts | 20 ++++ .../__tests__/anchor-overlay-settings.test.ts | 13 +++ .../settings/comfortable-anchor-range.ts | 15 +++ .../settings/developer-settings-screen.tsx | 79 ++++++++++++++++ .../settings/use-anchor-list-controller.ts | 7 +- .../pans/__tests__/pans-anchor-cache.test.ts | 93 +++++++++++++++++++ apps/mobile/src/pans/pans-anchor-cache.ts | 52 +++++++++++ .../src/field/render/field-anchor-layer.tsx | 26 ++++-- .../src/settings/__tests__/repository.test.ts | 15 +++ packages/mobile/src/settings/types.ts | 8 +- 14 files changed, 382 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts create mode 100644 apps/mobile/src/features/field/field-anchor-overlay-options.ts create mode 100644 apps/mobile/src/features/field/use-field-anchor-overlay.ts create mode 100644 apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts create mode 100644 apps/mobile/src/features/settings/comfortable-anchor-range.ts create mode 100644 apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts create mode 100644 apps/mobile/src/pans/pans-anchor-cache.ts diff --git a/apps/mobile/app/(tabs)/field/index.tsx b/apps/mobile/app/(tabs)/field/index.tsx index 59a867aa..7ffd7ff5 100644 --- a/apps/mobile/app/(tabs)/field/index.tsx +++ b/apps/mobile/app/(tabs)/field/index.tsx @@ -1,7 +1,15 @@ import { FieldScreen } from "../../../src/features/field/field-screen"; +import { useFieldAnchorOverlay } from "../../../src/features/field/use-field-anchor-overlay"; import { useFieldLivePosition } from "../../../src/pans/mobile-pans-context"; export default function FieldRoute() { const livePosition = useFieldLivePosition(); - return ; + const anchorOverlay = useFieldAnchorOverlay(); + return ( + + ); } diff --git a/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts b/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts new file mode 100644 index 00000000..0497773f --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts @@ -0,0 +1,32 @@ +import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; + +import { fieldAnchorOverlayOptions } from "../field-anchor-overlay-options"; + +describe("field anchor overlay options", () => { + test("requires Developer Mode and cached geometry before showing range", () => { + expect( + fieldAnchorOverlayOptions({ + ...DEFAULT_APP_SETTINGS, + showCachedAnchorGeometry: true, + showComfortableAnchorRange: true, + }), + ).toEqual({ visible: false, showRange: false, rangeMeters: 20 }); + expect( + fieldAnchorOverlayOptions({ + ...DEFAULT_APP_SETTINGS, + developerModeEnabled: true, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: true, + }), + ).toEqual({ visible: false, showRange: false, rangeMeters: 20 }); + expect( + fieldAnchorOverlayOptions({ + ...DEFAULT_APP_SETTINGS, + developerModeEnabled: true, + showCachedAnchorGeometry: true, + showComfortableAnchorRange: true, + comfortableAnchorRangeMeters: 30, + }), + ).toEqual({ visible: true, showRange: true, rangeMeters: 30 }); + }); +}); diff --git a/apps/mobile/src/features/field/field-anchor-overlay-options.ts b/apps/mobile/src/features/field/field-anchor-overlay-options.ts new file mode 100644 index 00000000..961a469c --- /dev/null +++ b/apps/mobile/src/features/field/field-anchor-overlay-options.ts @@ -0,0 +1,16 @@ +import type { FieldAnchorOverlayOptions } from "@eight2five/mobile/field"; +import { + getEffectiveDeveloperOverlaySettings, + type AppSettings, +} from "@eight2five/mobile/settings"; + +export function fieldAnchorOverlayOptions( + settings: AppSettings, +): FieldAnchorOverlayOptions { + const effective = getEffectiveDeveloperOverlaySettings(settings); + return { + visible: effective.showCachedAnchorGeometry, + showRange: effective.showComfortableAnchorRange, + rangeMeters: settings.comfortableAnchorRangeMeters, + }; +} diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index b02a2bf1..fe0c1979 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -21,6 +21,8 @@ import { CoordinatePanel } from "./coordinate-panel/coordinate-panel"; import { areCoordinatePanelControlsDisabled } from "./coordinate-panel/coordinate-panel-state"; import { PageDial } from "./page-dial/page-dial"; +const EMPTY_ANCHORS: readonly FieldAnchorGeometry[] = Object.freeze([]); + function setLivePositionValue( sharedValue: SharedValue, position: FieldPoint | null, @@ -30,7 +32,7 @@ function setLivePositionValue( export function FieldScreen({ livePosition, - anchors = [], + anchors = EMPTY_ANCHORS, anchorOverlayOptions, }: { readonly livePosition?: FieldLivePositionInput; @@ -81,8 +83,8 @@ export function FieldScreen({ livePosition: theme.accent, target: "#D29B22", guidance: theme.accent, - anchor: theme.warning, - anchorRange: theme.warningSoft, + anchor: theme.accent, + anchorRange: colorWithAlpha(theme.accent, "24"), }), [theme], ); @@ -148,3 +150,7 @@ export function FieldScreen({ /> ); } + +function colorWithAlpha(color: string, alpha: string): string { + return /^#[0-9a-f]{6}$/i.test(color) ? `${color}${alpha}` : color; +} diff --git a/apps/mobile/src/features/field/use-field-anchor-overlay.ts b/apps/mobile/src/features/field/use-field-anchor-overlay.ts new file mode 100644 index 00000000..38a6bed0 --- /dev/null +++ b/apps/mobile/src/features/field/use-field-anchor-overlay.ts @@ -0,0 +1,20 @@ +import React from "react"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { useMobilePansSnapshot } from "../../pans/mobile-pans-context"; +import { cachedAnchorGeometry } from "../../pans/pans-anchor-cache"; +import { fieldAnchorOverlayOptions } from "./field-anchor-overlay-options"; + +export function useFieldAnchorOverlay() { + const { settings } = useAppSettingsSnapshot(); + const pans = useMobilePansSnapshot(); + const anchors = React.useMemo( + () => cachedAnchorGeometry(pans.rememberedTag, pans.knownAnchors), + [pans.knownAnchors, pans.rememberedTag], + ); + const options = React.useMemo( + () => fieldAnchorOverlayOptions(settings), + [settings], + ); + return { anchors, options } as const; +} diff --git a/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts new file mode 100644 index 00000000..26cc9c5b --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts @@ -0,0 +1,13 @@ +import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; + +import { parseComfortableAnchorRange } from "../comfortable-anchor-range"; + +describe("anchor overlay settings", () => { + test("uses the planning default and accepts a finite bounded range", () => { + expect(DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters).toBe(20); + expect(parseComfortableAnchorRange("20.0")).toEqual({ value: 20 }); + expect(parseComfortableAnchorRange("0")).toHaveProperty("error"); + expect(parseComfortableAnchorRange("Infinity")).toHaveProperty("error"); + expect(parseComfortableAnchorRange("200.1")).toHaveProperty("error"); + }); +}); diff --git a/apps/mobile/src/features/settings/comfortable-anchor-range.ts b/apps/mobile/src/features/settings/comfortable-anchor-range.ts new file mode 100644 index 00000000..20ea2345 --- /dev/null +++ b/apps/mobile/src/features/settings/comfortable-anchor-range.ts @@ -0,0 +1,15 @@ +export interface ComfortableAnchorRangeResult { + readonly value?: number; + readonly error?: string; +} + +export function parseComfortableAnchorRange( + input: string, +): ComfortableAnchorRangeResult { + if (!input.trim()) return { error: "Enter a comfortable range in meters." }; + const value = Number(input); + if (!Number.isFinite(value)) return { error: "Range must be finite." }; + if (value <= 0) return { error: "Range must be greater than 0 meters." }; + if (value > 200) return { error: "Range must not exceed 200 meters." }; + return { value }; +} diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx index f14d5c40..63a469f9 100644 --- a/apps/mobile/src/features/settings/developer-settings-screen.tsx +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -2,8 +2,10 @@ import React from "react"; import { useRouter } from "expo-router"; import { Activity, + CircleDashed, Code2, Database, + MapPinned, RefreshCw, Radio, Triangle, @@ -32,7 +34,9 @@ import { useMobilePansStore, } from "../../pans/mobile-pans-context"; import { buildDeveloperDiagnosticRows } from "./developer-diagnostics"; +import { parseComfortableAnchorRange } from "./comfortable-anchor-range"; import { disableDeveloperMode } from "./developer-mode-actions"; +import { AnchorNumberInput } from "./standard-anchor-position-form"; import { SettingsMessage, SettingsNavigationRow, @@ -51,6 +55,9 @@ export function DeveloperSettingsScreen() { const pans = useMobilePansSnapshot(); const [refreshing, setRefreshing] = React.useState(false); const [operationError, setOperationError] = React.useState(); + const [rangeDraft, setRangeDraft] = React.useState(() => + settings.comfortableAnchorRangeMeters.toString(), + ); const rows = React.useMemo(() => buildDeveloperDiagnosticRows(pans), [pans]); const disable = async () => { @@ -79,6 +86,23 @@ export function DeveloperSettingsScreen() { } }; + const updateOverlay = async (partial: { + showCachedAnchorGeometry?: boolean; + showComfortableAnchorRange?: boolean; + comfortableAnchorRangeMeters?: number; + }) => { + setOperationError(undefined); + try { + await settingsStore.update(partial); + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } + }; + + const rangeValidation = parseComfortableAnchorRange(rangeDraft); + if (!settings.developerModeEnabled) { return ( @@ -187,6 +211,61 @@ export function DeveloperSettingsScreen() { testID="cached-anchors-link" /> + + + + void updateOverlay({ showCachedAnchorGeometry }) + } + testID="show-cached-anchor-geometry-setting" + /> + + void updateOverlay({ showComfortableAnchorRange }) + } + disabled={!settings.showCachedAnchorGeometry} + testID="show-comfortable-anchor-range-setting" + /> + + + + + ); } diff --git a/apps/mobile/src/features/settings/use-anchor-list-controller.ts b/apps/mobile/src/features/settings/use-anchor-list-controller.ts index ce148508..aa897949 100644 --- a/apps/mobile/src/features/settings/use-anchor-list-controller.ts +++ b/apps/mobile/src/features/settings/use-anchor-list-controller.ts @@ -6,6 +6,7 @@ import { useMobilePansSnapshot, useMobilePansStore, } from "../../pans/mobile-pans-context"; +import { selectNetworkAnchors } from "../../pans/pans-anchor-cache"; export function useAnchorListController() { const { settings } = useAppSettingsSnapshot(); @@ -14,6 +15,10 @@ export function useAnchorListController() { const [refreshing, setRefreshing] = React.useState(false); const refreshingRef = React.useRef(false); const [error, setError] = React.useState(); + const anchors = React.useMemo( + () => selectNetworkAnchors(pans.rememberedTag, pans.knownAnchors), + [pans.knownAnchors, pans.rememberedTag], + ); const refresh = React.useCallback(async () => { if (pans.initialization !== "ready" || refreshingRef.current) return; @@ -38,7 +43,7 @@ export function useAnchorListController() { return { developerModeEnabled: settings.developerModeEnabled, - anchors: pans.knownAnchors, + anchors, refreshing, error: error ?? pans.error, refresh, diff --git a/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts b/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts new file mode 100644 index 00000000..fe932e90 --- /dev/null +++ b/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts @@ -0,0 +1,93 @@ +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +import { + cachedAnchorGeometry, + selectNetworkAnchors, +} from "../pans-anchor-cache"; + +const base = { + transportDeviceId: "transport", + createdAt: 1, + updatedAt: 1, +}; + +describe("network-associated anchor cache", () => { + test("never mixes geometry from another saved network", () => { + const tag = { + ...base, + id: "tag", + role: "tag" as const, + networkId: "network-a", + }; + const anchors = [ + anchor("a", "network-a", 1), + anchor("b", "network-b", 2), + anchor("unassociated", undefined, 3), + ]; + + expect(selectNetworkAnchors(tag, anchors).map((item) => item.id)).toEqual([ + "a", + ]); + expect(cachedAnchorGeometry(tag, anchors)).toEqual([ + { + id: "a", + position: { xMeters: 1, yMeters: 2, zMeters: 3 }, + }, + ]); + }); + + test("falls back to equal verified PAN IDs and otherwise hides geometry", () => { + const tag = { + ...base, + id: "tag", + role: "tag" as const, + lastKnownConfig: tagConfig(44), + }; + const anchors = [ + anchor("same", undefined, 1, 44), + anchor("other", undefined, 2, 45), + ]; + expect(selectNetworkAnchors(tag, anchors).map((item) => item.id)).toEqual([ + "same", + ]); + expect( + selectNetworkAnchors({ ...tag, lastKnownConfig: tagConfig() }, anchors), + ).toEqual([]); + }); +}); + +function anchor( + id: string, + networkId: string | undefined, + xMeters: number, + panId = 44, +): ManagedDevice { + return { + ...base, + id, + ...(networkId ? { networkId } : {}), + role: "anchor", + lastKnownConfig: { + role: "anchor", + panId, + uwbMode: "active", + ledEnabled: true, + firmwareUpdateEnabled: false, + initiatorEnabled: false, + position: { xMeters, yMeters: 2, zMeters: 3, quality: 100 }, + }, + }; +} + +function tagConfig(panId?: number) { + return { + role: "tag" as const, + ...(panId === undefined ? {} : { panId }), + uwbMode: "active" as const, + ledEnabled: true, + firmwareUpdateEnabled: false, + locationEngineEnabled: true, + lowPowerModeEnabled: false, + stationaryDetectionEnabled: true, + }; +} diff --git a/apps/mobile/src/pans/pans-anchor-cache.ts b/apps/mobile/src/pans/pans-anchor-cache.ts new file mode 100644 index 00000000..bf2ae13c --- /dev/null +++ b/apps/mobile/src/pans/pans-anchor-cache.ts @@ -0,0 +1,52 @@ +import type { FieldAnchorGeometry } from "@eight2five/mobile/field"; +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +/** + * Selects only anchors explicitly associated with the remembered tag's saved + * network, falling back to a verified cached PAN ID when no profile exists. + */ +export function selectNetworkAnchors( + rememberedTag: ManagedDevice | undefined, + knownAnchors: readonly ManagedDevice[], +): readonly ManagedDevice[] { + if (!rememberedTag) return []; + if (rememberedTag.networkId) { + return knownAnchors.filter( + (anchor) => anchor.networkId === rememberedTag.networkId, + ); + } + const panId = rememberedTag.lastKnownConfig?.panId; + if (panId === undefined) return []; + return knownAnchors.filter( + (anchor) => anchor.lastKnownConfig?.panId === panId, + ); +} + +export function cachedAnchorGeometry( + rememberedTag: ManagedDevice | undefined, + knownAnchors: readonly ManagedDevice[], +): readonly FieldAnchorGeometry[] { + return selectNetworkAnchors(rememberedTag, knownAnchors).flatMap((anchor) => { + const position = + anchor.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig.position + : undefined; + if ( + !position || + !Number.isFinite(position.xMeters) || + !Number.isFinite(position.yMeters) + ) { + return []; + } + return [ + { + id: anchor.id, + position: { + xMeters: position.xMeters, + yMeters: position.yMeters, + zMeters: position.zMeters, + }, + }, + ]; + }); +} diff --git a/packages/mobile/src/field/render/field-anchor-layer.tsx b/packages/mobile/src/field/render/field-anchor-layer.tsx index c58fe45b..da9cee3a 100644 --- a/packages/mobile/src/field/render/field-anchor-layer.tsx +++ b/packages/mobile/src/field/render/field-anchor-layer.tsx @@ -26,15 +26,23 @@ export const FieldAnchorLayer = React.memo(function FieldAnchorLayer({ <> {options.showRange && options.rangeMeters > 0 ? anchors.map((anchor) => ( - + + + + )) : null} {anchors.map((anchor) => ( diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts index 182d0bfd..d5fd452c 100644 --- a/packages/mobile/src/settings/__tests__/repository.test.ts +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -3,6 +3,7 @@ import { SqliteSettingsRepository } from "../SqliteSettingsRepository"; import { DEFAULT_APP_SETTINGS, getEffectiveAppSettings, + getEffectiveDeveloperOverlaySettings, normalizeAppSettings, } from "../types"; @@ -133,6 +134,20 @@ describe("app settings", () => { }).selectedDrillPageId, ).toBeNull(); }); + + test("gates range behind geometry and rejects ranges over 200 meters", () => { + expect( + getEffectiveDeveloperOverlaySettings({ + ...DEFAULT_APP_SETTINGS, + developerModeEnabled: true, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: true, + }), + ).toMatchObject({ showComfortableAnchorRange: false }); + expect( + normalizeAppSettings({ comfortableAnchorRangeMeters: 201 }), + ).toMatchObject({ comfortableAnchorRangeMeters: 20 }); + }); }); class SettingsFakeDatabase { diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts index c9de4af1..3dab5f91 100644 --- a/packages/mobile/src/settings/types.ts +++ b/packages/mobile/src/settings/types.ts @@ -146,7 +146,8 @@ export function getEffectiveDeveloperOverlaySettings( const settings = getEffectiveAppSettings(value); return { showCachedAnchorGeometry: settings.showCachedAnchorGeometry, - showComfortableAnchorRange: settings.showComfortableAnchorRange, + showComfortableAnchorRange: + settings.showCachedAnchorGeometry && settings.showComfortableAnchorRange, }; } @@ -170,7 +171,10 @@ function booleanOrDefault(value: unknown, fallback: boolean): boolean { } function positiveFiniteOrDefault(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 + return typeof value === "number" && + Number.isFinite(value) && + value > 0 && + value <= 200 ? value : fallback; } From 3aa6e3560bef1bb5fbee807cc72b98840eaa84de Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sat, 1 Aug 2026 01:51:15 -0500 Subject: [PATCH 021/101] fix(mobile): polish MVP integration --- .../field/use-field-anchor-overlay.ts | 12 +- .../__tests__/settings-actions.test.ts | 2 +- .../features/settings/anchor-editor-form.ts | 8 +- .../settings/comfortable-anchor-range.ts | 7 +- .../settings/developer-diagnostics.ts | 20 + .../src/features/settings/settings-actions.ts | 2 +- .../settings/tag-connection-screen.tsx | 27 +- .../pans/__tests__/mobile-pans-store.test.ts | 179 ++++- .../pans/__tests__/pans-anchor-cache.test.ts | 3 + .../pans/mobile-pans-connection-controller.ts | 376 ++++++++++ apps/mobile/src/pans/mobile-pans-context.tsx | 29 +- .../src/pans/mobile-pans-device-cache.ts | 122 ++++ apps/mobile/src/pans/mobile-pans-model.ts | 120 ++++ .../pans/mobile-pans-position-publisher.ts | 145 ++++ apps/mobile/src/pans/mobile-pans-store.ts | 663 ++++-------------- apps/mobile/src/pans/pans-anchor-cache.ts | 9 + .../mobile/src/field/render/field-canvas.tsx | 4 +- .../pans-manager/PansPositionStreamService.ts | 4 +- packages/mobile/src/settings/types.ts | 7 +- 19 files changed, 1177 insertions(+), 562 deletions(-) create mode 100644 apps/mobile/src/pans/mobile-pans-connection-controller.ts create mode 100644 apps/mobile/src/pans/mobile-pans-device-cache.ts create mode 100644 apps/mobile/src/pans/mobile-pans-model.ts create mode 100644 apps/mobile/src/pans/mobile-pans-position-publisher.ts diff --git a/apps/mobile/src/features/field/use-field-anchor-overlay.ts b/apps/mobile/src/features/field/use-field-anchor-overlay.ts index 38a6bed0..f2c98820 100644 --- a/apps/mobile/src/features/field/use-field-anchor-overlay.ts +++ b/apps/mobile/src/features/field/use-field-anchor-overlay.ts @@ -1,16 +1,20 @@ import React from "react"; import { useAppSettingsSnapshot } from "../../state/app-settings-store"; -import { useMobilePansSnapshot } from "../../pans/mobile-pans-context"; +import { + useKnownPansAnchors, + useRememberedPansTag, +} from "../../pans/mobile-pans-context"; import { cachedAnchorGeometry } from "../../pans/pans-anchor-cache"; import { fieldAnchorOverlayOptions } from "./field-anchor-overlay-options"; export function useFieldAnchorOverlay() { const { settings } = useAppSettingsSnapshot(); - const pans = useMobilePansSnapshot(); + const rememberedTag = useRememberedPansTag(); + const knownAnchors = useKnownPansAnchors(); const anchors = React.useMemo( - () => cachedAnchorGeometry(pans.rememberedTag, pans.knownAnchors), - [pans.knownAnchors, pans.rememberedTag], + () => cachedAnchorGeometry(rememberedTag, knownAnchors), + [knownAnchors, rememberedTag], ); const options = React.useMemo( () => fieldAnchorOverlayOptions(settings), diff --git a/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts b/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts index 6148075e..a1dd17f2 100644 --- a/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts +++ b/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts @@ -47,7 +47,7 @@ describe("settings actions", () => { test("reset confirmation states destructive boundaries", () => { expect(RESET_SETTINGS_MESSAGE).toBe( "This restores display, drill-feature, terminology, and developer preferences to their defaults.\n\n" + - "It does not delete drills, cached anchor positions, or modify PANS hardware.", + "It does not delete drills, forget the remembered tag, delete cached anchor positions, or modify PANS hardware.", ); }); }); diff --git a/apps/mobile/src/features/settings/anchor-editor-form.ts b/apps/mobile/src/features/settings/anchor-editor-form.ts index cc9435fc..9acba1c4 100644 --- a/apps/mobile/src/features/settings/anchor-editor-form.ts +++ b/apps/mobile/src/features/settings/anchor-editor-form.ts @@ -6,6 +6,7 @@ import { anchorPositionUnitsToMeters, convertAnchorPositionUnits, formatMarchingCoordinate, + getAnchorPositionReferencePoint, parseAnchorPositionDraft, type AnchorFieldPosition, type AnchorPositionReference, @@ -22,6 +23,7 @@ import { export type AnchorEditorMode = "marching" | "standard"; export type MarchingHeightUnit = "meters" | "feet"; +const DEFAULT_ANCHOR_HEIGHT_METERS = 2; export interface MarchingAnchorDraft { readonly coordinate: MarchingCoordinateDraft; @@ -51,10 +53,10 @@ export function createAnchorEditorDrafts(position?: AnchorFieldPosition): { readonly marching: MarchingAnchorDraft; readonly standard: StandardAnchorPositionDraft; } { + const center = getAnchorPositionReferencePoint("center-field"); const initial = position ?? { - xMeters: 45.72, - yMeters: 24.384, - zMeters: 2, + ...center, + zMeters: DEFAULT_ANCHOR_HEIGHT_METERS, }; const coordinate = position ? pageToDraft({ diff --git a/apps/mobile/src/features/settings/comfortable-anchor-range.ts b/apps/mobile/src/features/settings/comfortable-anchor-range.ts index 20ea2345..4fe8f4ef 100644 --- a/apps/mobile/src/features/settings/comfortable-anchor-range.ts +++ b/apps/mobile/src/features/settings/comfortable-anchor-range.ts @@ -1,3 +1,4 @@ +import { MAX_COMFORTABLE_ANCHOR_RANGE_METERS } from "@eight2five/mobile/settings"; export interface ComfortableAnchorRangeResult { readonly value?: number; readonly error?: string; @@ -10,6 +11,10 @@ export function parseComfortableAnchorRange( const value = Number(input); if (!Number.isFinite(value)) return { error: "Range must be finite." }; if (value <= 0) return { error: "Range must be greater than 0 meters." }; - if (value > 200) return { error: "Range must not exceed 200 meters." }; + if (value > MAX_COMFORTABLE_ANCHOR_RANGE_METERS) { + return { + error: `Range must not exceed ${MAX_COMFORTABLE_ANCHOR_RANGE_METERS} meters.`, + }; + } return { value }; } diff --git a/apps/mobile/src/features/settings/developer-diagnostics.ts b/apps/mobile/src/features/settings/developer-diagnostics.ts index 0b1f1e42..d0ebbcd4 100644 --- a/apps/mobile/src/features/settings/developer-diagnostics.ts +++ b/apps/mobile/src/features/settings/developer-diagnostics.ts @@ -51,6 +51,26 @@ export function buildDeveloperDiagnosticRows( label: "Locally known anchors", value: snapshot.knownAnchors.length.toString(), }, + ...(snapshot.counters + ? [ + { + label: "Notification events", + value: snapshot.counters.notificationEvents.toString(), + }, + { + label: "Decoded position frames", + value: snapshot.counters.positionFrames.toString(), + }, + ] + : []), + ...snapshot.diagnosticMessages.map((message, index) => ({ + label: `Stream diagnostic ${index + 1}`, + value: message, + })), + ...(diagnostics?.warnings.map((warning) => ({ + label: `Hardware warning: ${warning.section}`, + value: warning.message, + })) ?? []), ...snapshot.knownAnchors.map(anchorDiagnosticRow), ]; } diff --git a/apps/mobile/src/features/settings/settings-actions.ts b/apps/mobile/src/features/settings/settings-actions.ts index 2e3e4654..15e10ab8 100644 --- a/apps/mobile/src/features/settings/settings-actions.ts +++ b/apps/mobile/src/features/settings/settings-actions.ts @@ -10,7 +10,7 @@ export interface SettingsWriter { export const RESET_SETTINGS_MESSAGE = "This restores display, drill-feature, terminology, and developer preferences to their defaults.\n\n" + - "It does not delete drills, cached anchor positions, or modify PANS hardware."; + "It does not delete drills, forget the remembered tag, delete cached anchor positions, or modify PANS hardware."; /** Persistence completes before the native-tab layout is reconfigured. */ export async function updateDrillFeatures( diff --git a/apps/mobile/src/features/settings/tag-connection-screen.tsx b/apps/mobile/src/features/settings/tag-connection-screen.tsx index 4b0a1069..50bc430d 100644 --- a/apps/mobile/src/features/settings/tag-connection-screen.tsx +++ b/apps/mobile/src/features/settings/tag-connection-screen.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useFocusEffect } from "expo-router"; import { Bluetooth, BluetoothConnected, @@ -24,6 +25,7 @@ import { useMobilePansSnapshot, useMobilePansStore, } from "../../pans/mobile-pans-context"; +import { isSelectableTagDiscovery } from "../../pans/mobile-pans-model"; import { SettingsMessage, SettingsScreenContainer, @@ -40,8 +42,10 @@ export function TagConnectionScreen() { const [operation, setOperation] = React.useState(); const [error, setError] = React.useState(); const busy = BUSY_STATES.has(snapshot.connectionState) || Boolean(operation); - const candidates = snapshot.discoveries.filter( - (device) => device.presence?.role !== "anchor", + const candidates = snapshot.discoveries.filter(isSelectableTagDiscovery); + + useFocusEffect( + React.useCallback(() => () => store.stopManualDiscovery(), [store]), ); const run = async (name: string, action: () => Promise) => { @@ -95,7 +99,11 @@ export function TagConnectionScreen() { + {busy ? ( + + ) : null} diff --git a/apps/mobile/src/features/drill/page-form.ts b/apps/mobile/src/features/drill/page-form.ts index e5e7655f..a0e14d1c 100644 --- a/apps/mobile/src/features/drill/page-form.ts +++ b/apps/mobile/src/features/drill/page-form.ts @@ -1,25 +1,33 @@ import { - STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, - fieldPointToMarchingCoordinate, + drillGridPointToMarchingCoordinate, + fieldPointToDrillGridPoint, formatMarchingFrontBack, formatMarchingSide, - marchingCoordinateToFieldPoint, + marchingCoordinateToDrillGridPoint, type FieldLateralReference, - type FieldPoint, type MarchingCoordinate, type MarchingFrontBackRelation, type MarchingSideReference, type MarchingSideRelation, } from "@eight2five/mobile/field"; +import type { + DrillGridPoint, + DrillSet, + MeasureRange, + SetKind, +} from "@eight2five/mobile/drill"; -export const PAGE_LABEL_MAX_LENGTH = 40; export const YARD_LINES = Object.freeze( Array.from({ length: 11 }, (_, index) => index * 5), ); export interface MarchingCoordinateDraft { - readonly label: string; + readonly setNumber: string; + readonly setKind: SetKind; + readonly setSuffix: string; readonly countsFromPrevious: string; + readonly measureStart: string; + readonly measureEnd: string; readonly side: "1" | "2" | "center"; readonly yardLine: string; readonly sideRelation: MarchingSideRelation; @@ -29,27 +37,33 @@ export interface MarchingCoordinateDraft { readonly frontBackOffsetSteps: string; } -export type PageFormField = - | "label" +export type SetFormField = + | "setNumber" + | "setSuffix" | "countsFromPrevious" + | "measureStart" + | "measureEnd" | "side" | "yardLine" | "sideOffsetSteps" | "frontBackOffsetSteps" | "coordinate"; -export type PageFormErrors = Partial>; +export type SetFormErrors = Partial>; -export interface ValidatedPageDraft { - readonly label: string; +export interface ValidatedSetDraft { + readonly number: number; + readonly kind: SetKind; + readonly suffix?: string; readonly countsFromPrevious: number; - readonly position: FieldPoint; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; readonly coordinate: MarchingCoordinate; } -export interface PageDraftValidation { - readonly errors: PageFormErrors; - readonly value?: ValidatedPageDraft; +export interface SetDraftValidation { + readonly errors: SetFormErrors; + readonly value?: ValidatedSetDraft; } export interface CoordinatePreview { @@ -59,14 +73,27 @@ export interface CoordinatePreview { export function createDefaultPageDraft({ ordinal, + suggestedNumber, suggestedLabel, }: { ordinal: number; - suggestedLabel: string; + suggestedNumber?: number; + /** @deprecated Legacy callers may still pass a numeric-ish label. */ + suggestedLabel?: string; }): MarchingCoordinateDraft { + const fallbackNumber = Number(suggestedLabel); + const resolvedNumber = + suggestedNumber ?? + (Number.isSafeInteger(fallbackNumber) && fallbackNumber >= 0 + ? fallbackNumber + : ordinal + 1); return { - label: suggestedLabel, + setNumber: String(resolvedNumber), + setKind: "set", + setSuffix: "", countsFromPrevious: ordinal === 0 ? "0" : "8", + measureStart: "", + measureEnd: "", side: "center", yardLine: "50", sideRelation: "on", @@ -77,15 +104,45 @@ export function createDefaultPageDraft({ }; } -export function pageToDraft(page: { - readonly label: string; - readonly countsFromPrevious: number; - readonly position: FieldPoint; +export function pageToDraft(set: DrillSet): MarchingCoordinateDraft { + return setDraftFromPosition(set.position, { + number: set.number, + kind: set.kind, + suffix: set.suffix, + countsFromPrevious: set.countsFromPrevious, + measureRange: set.measureRange, + }); +} + +export function coordinateDraftFromFieldPoint(position: { + readonly xMeters: number; + readonly yMeters: number; }): MarchingCoordinateDraft { - const coordinate = fieldPointToMarchingCoordinate(page.position); + return setDraftFromPosition(fieldPointToDrillGridPoint(position), { + number: 0, + kind: "set", + countsFromPrevious: 0, + }); +} + +function setDraftFromPosition( + position: DrillGridPoint, + details: { + readonly number: number; + readonly kind: SetKind; + readonly suffix?: string; + readonly countsFromPrevious: number; + readonly measureRange?: MeasureRange; + }, +): MarchingCoordinateDraft { + const coordinate = drillGridPointToMarchingCoordinate(position); return { - label: page.label, - countsFromPrevious: String(page.countsFromPrevious), + setNumber: String(details.number), + setKind: details.kind, + setSuffix: details.suffix ?? "", + countsFromPrevious: String(details.countsFromPrevious), + measureStart: details.measureRange ? String(details.measureRange.start) : "", + measureEnd: details.measureRange ? String(details.measureRange.end) : "", side: String(coordinate.side.side) as MarchingCoordinateDraft["side"], yardLine: String(coordinate.side.yardLine), sideRelation: coordinate.side.relation, @@ -98,24 +155,36 @@ export function pageToDraft(page: { export function validatePageDraft( draft: MarchingCoordinateDraft, -): PageDraftValidation { - const errors: PageFormErrors = {}; - const label = draft.label.trim(); - if (!label) errors.label = "Enter a label."; - else if (label.length > PAGE_LABEL_MAX_LENGTH) { - errors.label = `Labels must be ${PAGE_LABEL_MAX_LENGTH} characters or fewer.`; +): SetDraftValidation { + const errors: SetFormErrors = {}; + const setNumber = parseNonNegativeInteger( + draft.setNumber, + "Enter a non-negative set number.", + ); + if (typeof setNumber === "string") errors.setNumber = setNumber; + + const suffix = draft.setSuffix.trim(); + if (draft.setKind === "set" && suffix) { + errors.setSuffix = "Primary sets do not have a suffix."; + } else if ( + draft.setKind === "subset" && + !/^(?:[A-Z]|\.[0-9]+)$/.test(suffix) + ) { + errors.setSuffix = "Use one capital letter or a decimal suffix such as .5."; } - const counts = parseNonNegativeNumber( + const counts = parseNonNegativeInteger( draft.countsFromPrevious, - "Enter finite, non-negative counts.", + "Enter non-negative whole-number counts.", ); if (typeof counts === "string") errors.countsFromPrevious = counts; + const measureRange = parseMeasureRange(draft, errors); const coordinateResult = coordinateFromDraft(draft); Object.assign(errors, coordinateResult.errors); if ( Object.keys(errors).length > 0 || + typeof setNumber === "string" || typeof counts === "string" || !coordinateResult.coordinate || !coordinateResult.position @@ -126,8 +195,11 @@ export function validatePageDraft( return { errors, value: { - label, + number: setNumber, + kind: draft.setKind, + ...(draft.setKind === "subset" ? { suffix } : {}), countsFromPrevious: counts, + ...(measureRange ? { measureRange } : {}), coordinate: coordinateResult.coordinate, position: coordinateResult.position, }, @@ -138,43 +210,58 @@ export function previewCoordinate( draft: MarchingCoordinateDraft, ): CoordinatePreview | undefined { const result = coordinateFromDraft(draft); - if (!result.coordinate || Object.keys(result.errors).length > 0) { - return undefined; - } + if (!result.coordinate || Object.keys(result.errors).length > 0) return undefined; return { side: formatMarchingSide(result.coordinate.side), frontBack: formatMarchingFrontBack(result.coordinate.frontBack), }; } +function parseMeasureRange( + draft: MarchingCoordinateDraft, + errors: SetFormErrors, +): MeasureRange | undefined { + const startText = draft.measureStart.trim(); + const endText = draft.measureEnd.trim(); + if (!startText && !endText) return undefined; + if (!startText || !endText) { + const message = "Enter both measure start and end, or leave both blank."; + if (!startText) errors.measureStart = message; + if (!endText) errors.measureEnd = message; + return undefined; + } + const start = parseNonNegativeInteger(startText, "Enter a valid start measure."); + const end = parseNonNegativeInteger(endText, "Enter a valid end measure."); + if (typeof start === "string") errors.measureStart = start; + if (typeof end === "string") errors.measureEnd = end; + if (typeof start === "string" || typeof end === "string") return undefined; + if (end < start) { + errors.measureEnd = "End measure must be at or after the start measure."; + return undefined; + } + return { start, end }; +} + function coordinateFromDraft(draft: MarchingCoordinateDraft): { - readonly errors: PageFormErrors; + readonly errors: SetFormErrors; readonly coordinate?: MarchingCoordinate; - readonly position?: FieldPoint; + readonly position?: DrillGridPoint; } { - const errors: PageFormErrors = {}; - const yardLine = parseNonNegativeNumber( - draft.yardLine, - "Choose a five-yard line.", - ); + const errors: SetFormErrors = {}; + const yardLine = parseNonNegativeNumber(draft.yardLine, "Choose a five-yard line."); if (typeof yardLine === "string" || !YARD_LINES.includes(yardLine)) { errors.yardLine = "Choose a five-yard line from 0 through 50."; } - const sideOffset = parseNonNegativeNumber( draft.sideOffsetSteps, "Enter a finite, non-negative side offset.", ); if (typeof sideOffset === "string") errors.sideOffsetSteps = sideOffset; - const frontBackOffset = parseNonNegativeNumber( draft.frontBackOffsetSteps, "Enter a finite, non-negative front-to-back offset.", ); - if (typeof frontBackOffset === "string") { - errors.frontBackOffsetSteps = frontBackOffset; - } - + if (typeof frontBackOffset === "string") errors.frontBackOffsetSteps = frontBackOffset; if ( typeof yardLine === "string" || typeof sideOffset === "string" || @@ -188,11 +275,9 @@ function coordinateFromDraft(draft: MarchingCoordinateDraft): { errors.side = "Choose Side 1, Side 2, or no side for the 50."; return { errors }; } - if (side === "center" && yardLine !== 50) { errors.side = "No side is available only when exactly on the 50-yard line."; } - const normalizedSide = yardLine === 50 && sideOffset === 0 ? "center" : side; const normalizedSideRelation = sideOffset === 0 ? "on" : draft.sideRelation; if ( @@ -200,12 +285,9 @@ function coordinateFromDraft(draft: MarchingCoordinateDraft): { normalizedSide !== "center" && normalizedSideRelation !== "outside" ) { - errors.coordinate = - "An offset from the 50-yard line must be outside on Side 1 or Side 2."; + errors.coordinate = "An offset from the 50-yard line must be outside on Side 1 or Side 2."; } - // The domain preserves arbitrary fractional steps, so validation never rounds - // entered offsets; quarter-step values remain fully supported. const coordinate: MarchingCoordinate = { side: { side: normalizedSide, @@ -219,45 +301,34 @@ function coordinateFromDraft(draft: MarchingCoordinateDraft): { offsetSteps: frontBackOffset, }, }; - if (Object.keys(errors).length > 0) return { errors, coordinate }; + try { - const position = marchingCoordinateToFieldPoint(coordinate); - if (!isInFieldBounds(position)) { - errors.coordinate = "The coordinate must remain within the field bounds."; - return { errors, coordinate }; - } - return { errors, coordinate, position }; + return { + errors, + coordinate, + position: marchingCoordinateToDrillGridPoint(coordinate), + }; } catch (cause) { errors.coordinate = cause instanceof Error ? cause.message : String(cause); return { errors, coordinate }; } } -function parseNonNegativeNumber( - value: string, - message: string, -): number | string { +function parseNonNegativeInteger(value: string, message: string): number | string { + if (!value.trim()) return message; + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 ? number : message; +} + +function parseNonNegativeNumber(value: string, message: string): number | string { if (!value.trim()) return message; const number = Number(value); return Number.isFinite(number) && number >= 0 ? number : message; } -function parseSide( - value: MarchingCoordinateDraft["side"], -): MarchingSideReference | undefined { +function parseSide(value: MarchingCoordinateDraft["side"]): MarchingSideReference | undefined { if (value === "1") return 1; if (value === "2") return 2; return value === "center" ? "center" : undefined; } - -function isInFieldBounds(point: FieldPoint): boolean { - const { bounds } = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; - const epsilon = 1e-8; - return ( - point.xMeters >= bounds.minXMeters - epsilon && - point.xMeters <= bounds.maxXMeters + epsilon && - point.yMeters >= bounds.minYMeters - epsilon && - point.yMeters <= bounds.maxYMeters + epsilon - ); -} diff --git a/apps/mobile/src/features/drill/page-management.ts b/apps/mobile/src/features/drill/page-management.ts index 95d2b57c..fd9135a7 100644 --- a/apps/mobile/src/features/drill/page-management.ts +++ b/apps/mobile/src/features/drill/page-management.ts @@ -1,21 +1,25 @@ -import type { DrillPage, DrillRepository } from "@eight2five/mobile/drill"; +import type { DrillRepository, DrillSet } from "@eight2five/mobile/drill"; import { validatePageDraft, type MarchingCoordinateDraft } from "./page-form"; -export type PagePlacement = "append" | "before" | "after"; -export type PageMoveDirection = "up" | "down"; +export type SetPlacement = "append" | "before" | "after"; +export type SetMoveDirection = "up" | "down"; +/** @deprecated Use SetPlacement. */ +export type PagePlacement = SetPlacement; +/** @deprecated Use SetMoveDirection. */ +export type PageMoveDirection = SetMoveDirection; -export function normalizePagePlacement(value: unknown): PagePlacement { +export function normalizePagePlacement(value: unknown): SetPlacement { return value === "before" || value === "after" ? value : "append"; } export function getPageCreationOrdinal( - pages: readonly DrillPage[], - placement: PagePlacement, - relativePageId?: string, + sets: readonly DrillSet[], + placement: SetPlacement, + relativeSetId?: string, ): number { - if (placement === "append") return pages.length; - const relativeIndex = pages.findIndex((page) => page.id === relativePageId); + if (placement === "append") return sets.length; + const relativeIndex = sets.findIndex((set) => set.id === relativeSetId); if (relativeIndex < 0) { throw new Error("The selected insertion point no longer exists."); } @@ -34,43 +38,50 @@ export async function savePageDraft({ repository: DrillRepository; drillId: string; pageId: string; - pages: readonly DrillPage[]; - placement: PagePlacement; + pages: readonly DrillSet[]; + placement: SetPlacement; relativePageId?: string; draft: MarchingCoordinateDraft; -}): Promise { +}): Promise { const validation = validatePageDraft(draft); if (!validation.value) { const message = - Object.values(validation.errors)[0] ?? "Review the entry form."; + Object.values(validation.errors)[0] ?? "Review the set form."; throw new Error(message); } const details = { - label: validation.value.label, + number: validation.value.number, + kind: validation.value.kind, + ...(validation.value.suffix === undefined + ? {} + : { suffix: validation.value.suffix }), countsFromPrevious: validation.value.countsFromPrevious, + ...(validation.value.measureRange === undefined + ? {} + : { measureRange: validation.value.measureRange }), position: validation.value.position, }; if (pageId !== "new") { - return await repository.updatePage(pageId, details); + return await repository.updateSet(pageId, details); } const ordinal = getPageCreationOrdinal(pages, placement, relativePageId); if (placement === "append") { - return await repository.createPage({ drillId, ...details }); + return await repository.createSet({ drillId, ...details }); } - return await repository.insertPage(drillId, ordinal, details); + return await repository.insertSet(drillId, ordinal, details); } export function reorderedPageIds( - pages: readonly DrillPage[], - pageId: string, - direction: PageMoveDirection, + sets: readonly DrillSet[], + setId: string, + direction: SetMoveDirection, ): readonly string[] | undefined { - const index = pages.findIndex((page) => page.id === pageId); - if (index < 0) throw new Error("The entry to move no longer exists."); + const index = sets.findIndex((set) => set.id === setId); + if (index < 0) throw new Error("The set to move no longer exists."); const destination = direction === "up" ? index - 1 : index + 1; - if (destination < 0 || destination >= pages.length) return undefined; - const ids = pages.map((page) => page.id); + if (destination < 0 || destination >= sets.length) return undefined; + const ids = sets.map((set) => set.id); [ids[index], ids[destination]] = [ids[destination], ids[index]]; return ids; } @@ -78,20 +89,20 @@ export function reorderedPageIds( export async function movePage( repository: DrillRepository, drillId: string, - pages: readonly DrillPage[], - pageId: string, - direction: PageMoveDirection, -): Promise { - const ids = reorderedPageIds(pages, pageId, direction); - return ids ? await repository.reorderPages(drillId, ids) : pages; + sets: readonly DrillSet[], + setId: string, + direction: SetMoveDirection, +): Promise { + const ids = reorderedPageIds(sets, setId, direction); + return ids ? await repository.reorderSets(drillId, ids) : sets; } export async function deletePageAndRefreshSettings( repository: DrillRepository, - pageId: string, + setId: string, reloadSettings: () => Promise, ): Promise { - await repository.deletePage(pageId); - // Publish the selected-page pointer cleared by SQLite's foreign key. + await repository.deleteSet(setId); + // Publish the selected-set pointer cleared by SQLite's foreign key. await reloadSettings(); } diff --git a/apps/mobile/src/features/drill/use-drill-editor-controller.ts b/apps/mobile/src/features/drill/use-drill-editor-controller.ts index 1eb19fc2..1ed26a36 100644 --- a/apps/mobile/src/features/drill/use-drill-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-editor-controller.ts @@ -3,7 +3,7 @@ import { useFocusEffect } from "expo-router"; import { getDrillTerms, type Drill, - type DrillPage, + type DrillSet, } from "@eight2five/mobile/drill"; import { @@ -26,7 +26,7 @@ export function useDrillEditorController(drillId?: string) { const snapshot = useAppSettingsSnapshot(); const store = useAppSettingsStore(); const [drill, setDrill] = React.useState(); - const [pages, setPages] = React.useState([]); + const [pages, setPages] = React.useState([]); const [loading, setLoading] = React.useState(Boolean(drillId)); const [saving, setSaving] = React.useState(false); const [busyPageId, setBusyPageId] = React.useState(); @@ -39,7 +39,7 @@ export function useDrillEditorController(drillId?: string) { const repository = store.getDrillRepository(); const [nextDrill, nextPages] = await Promise.all([ repository.getDrill(drillId), - repository.listPages(drillId), + repository.listSets(drillId), ]); if (!nextDrill) throw new Error("This drill no longer exists."); setDrill(nextDrill); @@ -126,7 +126,7 @@ export function useDrillEditorController(drillId?: string) { }, [drillId, store]); const selectPage = React.useCallback( - async (page: DrillPage) => { + async (page: DrillSet) => { if (snapshot.settings.activeDrillId !== drillId) { const operationError = new Error( "Make this drill active before selecting one of its entries.", @@ -139,7 +139,7 @@ export function useDrillEditorController(drillId?: string) { setBusyPageId(page.id); setError(undefined); try { - await store.setSelectedDrillPage(page.id); + await store.setSelectedDrillSet(page.id); } catch (cause) { const operationError = toError(cause); setError(operationError); @@ -153,7 +153,7 @@ export function useDrillEditorController(drillId?: string) { ); const move = React.useCallback( - async (page: DrillPage, direction: PageMoveDirection) => { + async (page: DrillSet, direction: PageMoveDirection) => { if (!drillId || operationInFlight.current) return; operationInFlight.current = true; setBusyPageId(page.id); @@ -181,7 +181,7 @@ export function useDrillEditorController(drillId?: string) { ); const removePage = React.useCallback( - async (page: DrillPage) => { + async (page: DrillSet) => { if (!drillId || operationInFlight.current) return; operationInFlight.current = true; setBusyPageId(page.id); @@ -192,7 +192,7 @@ export function useDrillEditorController(drillId?: string) { page.id, () => store.reload(), ); - setPages(await store.getDrillRepository().listPages(drillId)); + setPages(await store.getDrillRepository().listSets(drillId)); } catch (cause) { const operationError = toError(cause); setError(operationError); @@ -213,8 +213,8 @@ export function useDrillEditorController(drillId?: string) { saving, busyPageId, active: snapshot.settings.activeDrillId === drillId, - selectedPageId: snapshot.settings.selectedDrillPageId, - terms: getDrillTerms(snapshot.settings.drillTerminology), + selectedPageId: snapshot.settings.selectedDrillSetId, + terms: getDrillTerms("sets"), error: error ?? snapshot.error, refresh, saveName, diff --git a/apps/mobile/src/features/drill/use-drill-list-controller.ts b/apps/mobile/src/features/drill/use-drill-list-controller.ts index e019a32a..a7a4d0fe 100644 --- a/apps/mobile/src/features/drill/use-drill-list-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-list-controller.ts @@ -118,7 +118,7 @@ export function useDrillListController() { error: error ?? snapshot.error, busyDrillId, activeDrillId: snapshot.settings.activeDrillId, - terms: getDrillTerms(snapshot.settings.drillTerminology), + terms: getDrillTerms("sets"), refresh, rename, makeActive, diff --git a/apps/mobile/src/features/drill/use-page-editor-controller.ts b/apps/mobile/src/features/drill/use-page-editor-controller.ts index b5019bb9..4a82c35f 100644 --- a/apps/mobile/src/features/drill/use-page-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-page-editor-controller.ts @@ -1,6 +1,6 @@ import React from "react"; import { useFocusEffect } from "expo-router"; -import { getDrillTerms, type DrillPage } from "@eight2five/mobile/drill"; +import { getDrillTerms, type DrillSet } from "@eight2five/mobile/drill"; import { useAppSettingsSnapshot, @@ -26,8 +26,8 @@ export function usePageEditorController( ) { const snapshot = useAppSettingsSnapshot(); const store = useAppSettingsStore(); - const [page, setPage] = React.useState(); - const [pages, setPages] = React.useState([]); + const [page, setPage] = React.useState(); + const [pages, setPages] = React.useState([]); const [draft, setDraft] = React.useState(); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); @@ -38,7 +38,7 @@ export function usePageEditorController( if (snapshot.status !== "ready") return; try { const repository = store.getDrillRepository(); - const nextPages = await repository.listPages(drillId); + const nextPages = await repository.listSets(drillId); setPages(nextPages); if (pageId === "new") { const ordinal = getPageCreationOrdinal( @@ -46,17 +46,20 @@ export function usePageEditorController( placement, relativePageId, ); - // Count-based labels are only an editable suggestion; existing labels - // are never parsed or assumed to form a numeric sequence. + const highestPrimaryNumber = nextPages.reduce( + (highest, set) => + set.kind === "set" ? Math.max(highest, set.number) : highest, + -1, + ); setDraft( createDefaultPageDraft({ ordinal, - suggestedLabel: String(nextPages.length + 1), + suggestedNumber: highestPrimaryNumber + 1, }), ); setPage(undefined); } else { - const nextPage = await repository.getPage(pageId); + const nextPage = await repository.getSet(pageId); if (!nextPage || nextPage.drillId !== drillId) { throw new Error("This drill entry no longer exists in the drill."); } @@ -113,7 +116,7 @@ export function usePageEditorController( setDraft, loading: snapshot.status === "loading" || loading, saving, - terms: getDrillTerms(snapshot.settings.drillTerminology), + terms: getDrillTerms("sets"), error: error ?? snapshot.error, save, } as const; diff --git a/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts b/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts index 8c98e513..be78eaf0 100644 --- a/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts +++ b/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts @@ -1,4 +1,5 @@ -import type { DrillPage } from "@eight2five/mobile/drill"; +import type { DrillSet } from "@eight2five/mobile/drill"; +import { drillGridPointToFieldPoint } from "@eight2five/mobile/field"; import { areCoordinatePanelControlsDisabled, @@ -6,25 +7,29 @@ import { getLiveCoordinatePresentation, } from "../coordinate-panel-state"; -const first: DrillPage = { - id: "p1", +const first: DrillSet = { + id: "s1", drillId: "d1", ordinal: 0, - label: "1", + number: 31, + kind: "set", countsFromPrevious: 0, - position: { xMeters: 36.576, yMeters: 4.064 }, + measureRange: { start: 122, end: 125 }, + position: { xSteps: -16, ySteps: 7 }, }; -const second: DrillPage = { - id: "p2", +const second: DrillSet = { + id: "s2", drillId: "d1", ordinal: 1, - label: "2", + number: 32, + kind: "set", countsFromPrevious: 8, - position: { xMeters: 41.148, yMeters: 4.064 }, + measureRange: { start: 126, end: 129 }, + position: { xSteps: -8, ySteps: 7 }, }; describe("coordinate panel state", () => { - test("presents waiting, live, and stale states", () => { + test("presents waiting, live, and stale physical-position states", () => { expect( getLiveCoordinatePresentation({ connectionState: "idle", @@ -35,36 +40,37 @@ describe("coordinate panel state", () => { secondary: "Connect a PANS tag to begin", muted: true, }); + const livePosition = drillGridPointToFieldPoint(second.position); expect( getLiveCoordinatePresentation({ connectionState: "connected", - position: second.position, + position: livePosition, isStale: false, }).primary, ).toContain("Side 1"); expect( getLiveCoordinatePresentation({ connectionState: "disconnected", - position: second.position, + position: livePosition, isStale: true, }), ).toMatchObject({ statusLabel: "Last known position", muted: true }); }); - test("keeps the empty drill model stable and terminology-aware", () => { + test("uses fixed Set terminology and separate count/measure fields", () => { expect( getDrillCoordinatePresentation({ - terminology: "sets", metricMode: "step-size", }), ).toEqual({ term: "Set", - page: "–", + set: "–", counts: "–", + measures: "–", metricLabel: "Step Size", metric: "–", coordinate: null, - emptyMessage: "No drill page selected", + emptyMessage: "No drill set selected", }); }); @@ -72,26 +78,34 @@ describe("coordinate panel state", () => { const stepSize = getDrillCoordinatePresentation({ page: second, previousPage: first, - terminology: "pages", metricMode: "step-size", }); const crossingCounts = getDrillCoordinatePresentation({ page: second, previousPage: first, - terminology: "pages", metricMode: "crossing-counts", }); expect(stepSize).toMatchObject({ - term: "Page", - page: "2", + term: "Set", + set: "32", counts: "8", + measures: "126–129", metricLabel: "Step Size", metric: "8 to 5", }); expect(crossingCounts.metricLabel).toBe("xCounts"); }); + test("shows zero counts for the first set instead of using an unavailable marker", () => { + expect( + getDrillCoordinatePresentation({ + page: first, + metricMode: "step-size", + }).counts, + ).toBe("0"); + }); + test("disables controls until storage and drill data are ready", () => { expect( areCoordinatePanelControlsDisabled({ diff --git a/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts b/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts index 24da1859..ac2b42ea 100644 --- a/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts +++ b/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts @@ -3,8 +3,20 @@ import type { Drill } from "@eight2five/mobile/drill"; import { createDrillMenuActions } from "../drill-menu-state"; const drills: Drill[] = [ - { id: "one", name: "Opener 2026", createdAt: 1, updatedAt: 1 }, - { id: "two", name: "Closer", createdAt: 2, updatedAt: 2 }, + { + id: "one", + name: "Opener 2026", + fieldPreset: "football-nfhs", + createdAt: 1, + updatedAt: 1, + }, + { + id: "two", + name: "Closer", + fieldPreset: "football-nfhs", + createdAt: 2, + updatedAt: 2, + }, ]; describe("active drill menu", () => { diff --git a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts index c8ae92bc..3f4d8886 100644 --- a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts +++ b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts @@ -1,14 +1,11 @@ import { + drillGridPointToMarchingCoordinate, fieldPointToMarchingCoordinate, formatMarchingFrontBack, formatMarchingSide, type FieldLivePositionState, } from "@eight2five/mobile/field"; -import { - getDrillTerms, - type DrillPage, - type DrillTerminology, -} from "@eight2five/mobile/drill"; +import { formatSetName, type DrillSet } from "@eight2five/mobile/drill"; import type { TransitionMetricMode } from "@eight2five/mobile/settings"; import { getTransitionPresentation } from "../../drill/transition-presentation"; @@ -26,9 +23,10 @@ export interface LiveCoordinatePresentation { } export interface DrillCoordinatePresentation { - readonly term: "Page" | "Set"; - readonly page: string; + readonly term: "Set"; + readonly set: string; readonly counts: string; + readonly measures: string; readonly metricLabel: "Step Size" | "xCounts"; readonly metric: string; readonly coordinate: CoordinateLines | null; @@ -47,10 +45,10 @@ export function areCoordinatePanelControlsDisabled({ return !settingsReady || loadingDrills || selectionBusy; } -export function formatCoordinateLines( - position: DrillPage["position"], +export function formatDrillCoordinateLines( + position: DrillSet["position"], ): CoordinateLines { - const coordinate = fieldPointToMarchingCoordinate(position); + const coordinate = drillGridPointToMarchingCoordinate(position); return { side: formatMarchingSide(coordinate.side), frontBack: formatMarchingFrontBack(coordinate.frontBack), @@ -70,11 +68,11 @@ export function getLiveCoordinatePresentation( muted: true, }; } - const coordinate = formatCoordinateLines(live.position); + const coordinate = fieldPointToMarchingCoordinate(live.position); return { ...(live.isStale ? { statusLabel: "Last known position" } : {}), - primary: coordinate.side, - secondary: coordinate.frontBack, + primary: formatMarchingSide(coordinate.side), + secondary: formatMarchingFrontBack(coordinate.frontBack), muted: live.isStale, }; } @@ -82,37 +80,42 @@ export function getLiveCoordinatePresentation( export function getDrillCoordinatePresentation({ page, previousPage, - terminology, metricMode, }: { - readonly page?: DrillPage; - readonly previousPage?: DrillPage; - readonly terminology: DrillTerminology; + readonly page?: DrillSet; + readonly previousPage?: DrillSet; readonly metricMode: TransitionMetricMode; + /** @deprecated Sets are the only v2 terminology. */ + readonly terminology?: unknown; }): DrillCoordinatePresentation { - const term = getDrillTerms(terminology).singular; const metricLabel = metricMode === "step-size" ? "Step Size" : "xCounts"; if (!page) { return { - term, - page: "–", + term: "Set", + set: "–", counts: "–", + measures: "–", metricLabel, metric: "–", coordinate: null, - emptyMessage: "No drill page selected", + emptyMessage: "No drill set selected", }; } const transition = getTransitionPresentation(previousPage, page); return { - term, - page: page.label || String(page.ordinal + 1), - counts: previousPage ? String(page.countsFromPrevious) : "–", + term: "Set", + set: formatSetName(page), + counts: String(page.countsFromPrevious), + measures: page.measureRange + ? page.measureRange.start === page.measureRange.end + ? String(page.measureRange.start) + : `${page.measureRange.start}–${page.measureRange.end}` + : "–", metricLabel, metric: metricMode === "step-size" ? transition.stepSize : transition.crossingCounts, - coordinate: formatCoordinateLines(page.position), + coordinate: formatDrillCoordinateLines(page.position), }; } diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx index d4ed1a41..f354d920 100644 --- a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx +++ b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx @@ -1,7 +1,7 @@ import { HStack } from "@eight2five/ui/components/hstack"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; -import type { DrillPage, DrillTerminology } from "@eight2five/mobile/drill"; +import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; import type { TransitionMetricMode } from "@eight2five/mobile/settings"; import { getDrillCoordinatePresentation } from "./coordinate-panel-state"; @@ -58,15 +58,16 @@ function DrillCoordinate({ export function DrillCoordinateRow({ page, previousPage, - terminology, + terminology: _terminology, metricMode, landscape, metricToggleDisabled, onToggleMetric, }: { - readonly page?: DrillPage; - readonly previousPage?: DrillPage; - readonly terminology: DrillTerminology; + readonly page?: DrillSet; + readonly previousPage?: DrillSet; + /** @deprecated Sets are the only terminology; kept for call-site compatibility. */ + readonly terminology?: DrillTerminology; readonly metricMode: TransitionMetricMode; readonly landscape: boolean; readonly metricToggleDisabled: boolean; @@ -75,13 +76,13 @@ export function DrillCoordinateRow({ const presentation = getDrillCoordinatePresentation({ page, previousPage, - terminology, metricMode, }); const metadata = ( - + + + {metadata} ({ canvasBackground: theme.background, @@ -115,7 +118,7 @@ export function FieldScreen({ activeDrill={controller.activeDrill} selectedPage={controller.selectedPage} previousPage={controller.previousPage} - terminology={controller.settings.drillTerminology} + terminology="sets" metricMode={controller.settings.transitionMetricMode} controlsDisabled={areCoordinatePanelControlsDisabled({ settingsReady: controller.settingsStatus === "ready", @@ -135,9 +138,13 @@ export function FieldScreen({ diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts index d2d31a66..bbd7b29c 100644 --- a/apps/mobile/src/features/field/use-field-screen-controller.ts +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -2,7 +2,7 @@ import React from "react"; import { useFocusEffect } from "expo-router"; import { useWindowDimensions } from "react-native"; import type { FieldViewport } from "@eight2five/mobile/field"; -import type { Drill, DrillPage } from "@eight2five/mobile/drill"; +import type { Drill, DrillSet } from "@eight2five/mobile/drill"; import { useFieldOrientation } from "../../navigation/use-field-orientation"; import { @@ -25,7 +25,7 @@ export function useFieldScreenController() { const [initialViewport] = React.useState(() => committedFieldViewport); const [drills, setDrills] = React.useState([]); const [activeDrill, setActiveDrill] = React.useState(); - const [pages, setPages] = React.useState([]); + const [pages, setPages] = React.useState([]); const [loadingDrills, setLoadingDrills] = React.useState(true); const [fieldError, setFieldError] = React.useState(); const [selectionBusy, setSelectionBusy] = React.useState(false); @@ -49,7 +49,7 @@ export function useFieldScreenController() { const [nextDrills, nextActiveDrill, nextPages] = await Promise.all([ repository.listDrills(), activeDrillId ? repository.getDrill(activeDrillId) : undefined, - activeDrillId ? repository.listPages(activeDrillId) : [], + activeDrillId ? repository.listSets(activeDrillId) : [], ]); if (generation !== refreshGeneration.current) return; setDrills(nextDrills); @@ -113,7 +113,7 @@ export function useFieldScreenController() { }); setFieldError(undefined); try { - await store.setSelectedDrillPage(page.id); + await store.setSelectedDrillSet(page.id); if (generation === pageSelectionGeneration.current) { setOptimisticSelection(undefined); } @@ -132,7 +132,7 @@ export function useFieldScreenController() { const effectiveSelectedPageId = optimisticSelection?.activeDrillId === snapshot.settings.activeDrillId ? optimisticSelection.pageId - : snapshot.settings.selectedDrillPageId; + : snapshot.settings.selectedDrillSetId; const selectedIndex = pages.findIndex( (page) => page.id === effectiveSelectedPageId, ); diff --git a/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts index 1357da50..a320bc63 100644 --- a/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts +++ b/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts @@ -11,7 +11,7 @@ import { import { confirmAnchorPositionWrite } from "../anchor-write-confirmation"; describe("anchor editor form", () => { - test("reuses the marching page coordinate domain", () => { + test("reuses the marching drill-grid coordinate domain", () => { const draft = createAnchorEditorDrafts(); const result = validateMarchingAnchorDraft({ ...draft.marching, @@ -21,7 +21,7 @@ describe("anchor editor form", () => { expect(result.errors).toEqual({}); expect(result.position).toMatchObject({ - xMeters: 45.72, + xMeters: 0, yMeters: 0, zMeters: expect.closeTo(1.8288, 8), }); diff --git a/apps/mobile/src/features/settings/anchor-editor-form.ts b/apps/mobile/src/features/settings/anchor-editor-form.ts index 9acba1c4..d4190d34 100644 --- a/apps/mobile/src/features/settings/anchor-editor-form.ts +++ b/apps/mobile/src/features/settings/anchor-editor-form.ts @@ -15,8 +15,8 @@ import { } from "@eight2five/mobile/field"; import { + coordinateDraftFromFieldPoint, createDefaultPageDraft, - pageToDraft, validatePageDraft, type MarchingCoordinateDraft, } from "../drill/page-form"; @@ -59,12 +59,8 @@ export function createAnchorEditorDrafts(position?: AnchorFieldPosition): { zMeters: DEFAULT_ANCHOR_HEIGHT_METERS, }; const coordinate = position - ? pageToDraft({ - label: "Anchor", - countsFromPrevious: 0, - position, - }) - : createDefaultPageDraft({ ordinal: 0, suggestedLabel: "Anchor" }); + ? coordinateDraftFromFieldPoint(position) + : createDefaultPageDraft({ ordinal: 0, suggestedNumber: 0 }); const standard = anchorFieldPositionToStandard( initial, "center-field", diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx index ec278fce..f09cd9a1 100644 --- a/apps/mobile/src/features/settings/anchor-editor-screen.tsx +++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx @@ -100,7 +100,6 @@ export function AnchorEditorScreen({ diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 94bab8f7..58f5c1bb 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -6,9 +6,7 @@ import { ListChecks, Radio, SlidersHorizontal, - Tags, } from "lucide-react-native"; -import type { DrillTerminology } from "@eight2five/mobile/drill"; import type { AppSettingsUpdate, FieldPerspective, @@ -32,11 +30,6 @@ import { SettingsValueRow, } from "./settings-components"; -const TERMINOLOGY_CHOICES = [ - { label: "Pages", value: "pages" }, - { label: "Sets", value: "sets" }, -] as const; - const PERSPECTIVE_CHOICES = [ { label: "Director", value: "director" }, { label: "Performer", value: "performer" }, @@ -102,22 +95,12 @@ export function SettingsScreen() { void setDrillFeatures(enabled)} disabled={disabled} testID="drill-features-setting" /> - - icon={Tags} - title="Drill terminology" - description="Choose whether the app says Pages or Sets." - value={settings.drillTerminology} - choices={TERMINOLOGY_CHOICES} - onChange={(drillTerminology) => void update({ drillTerminology })} - disabled={disabled} - testID="drill-terminology-setting" - /> diff --git a/apps/mobile/src/state/app-settings-store.tsx b/apps/mobile/src/state/app-settings-store.tsx index 67550420..005d2c1b 100644 --- a/apps/mobile/src/state/app-settings-store.tsx +++ b/apps/mobile/src/state/app-settings-store.tsx @@ -107,14 +107,19 @@ export class AppSettingsStore { }); } - async setSelectedDrillPage(id: string | null): Promise { + async setSelectedDrillSet(id: string | null): Promise { return await this.enqueue(async (storage) => { - const settings = await storage.drillRepository.setSelectedDrillPage(id); + const settings = await storage.drillRepository.setSelectedDrillSet(id); this.publish(Object.freeze({ status: "ready", settings })); return settings; }); } + /** @deprecated Use setSelectedDrillSet. */ + async setSelectedDrillPage(id: string | null): Promise { + return await this.setSelectedDrillSet(id); + } + async reload(): Promise { return await this.enqueue(async (storage) => { const settings = await storage.settingsRepository.load(); diff --git a/package-lock.json b/package-lock.json index 8f87c2f4..f1da613f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2184,6 +2184,10 @@ "node": ">=0.8.0" } }, + "node_modules/@eight2five/drill-schema": { + "resolved": "packages/drill-schema", + "link": true + }, "node_modules/@eight2five/mobile": { "resolved": "packages/mobile", "link": true @@ -18976,10 +18980,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/drill-schema": { + "name": "@eight2five/drill-schema", + "version": "0.0.0", + "dependencies": { + "zod": "^3.25.76" + } + }, "packages/mobile": { "name": "@eight2five/mobile", "version": "0.0.0", "dependencies": { + "@eight2five/drill-schema": "*", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", diff --git a/packages/mobile/src/drill/SqliteDrillRepository.ts b/packages/mobile/src/drill/SqliteDrillRepository.ts index 3fd1e023..5139c32a 100644 --- a/packages/mobile/src/drill/SqliteDrillRepository.ts +++ b/packages/mobile/src/drill/SqliteDrillRepository.ts @@ -1,13 +1,15 @@ +import { formatSetName, type DrillGridPoint, type MeasureRange, type SetKind } from "@eight2five/drill-schema"; import type { SQLiteDatabase } from "expo-sqlite"; -import { assertFiniteFieldPoint, type FieldPoint } from "../field/types"; + +import { drillGridPointToFieldPoint } from "../field/marching"; import { APP_SETTINGS_TABLE, - DRILL_PAGES_TABLE, DRILLS_TABLE, + DRILL_SETS_TABLE, } from "../storage/mobileDatabase"; import { SqliteSettingsRepository } from "../settings/SqliteSettingsRepository"; import type { AppSettings } from "../settings/types"; -import type { Drill, DrillPage } from "./types"; +import type { Drill, DrillSet } from "./types"; type SqlValue = string | number | null; type Row = Record; @@ -17,25 +19,41 @@ export interface CreateDrillInput { readonly name: string; readonly createdAt?: number; readonly updatedAt?: number; + readonly fieldPreset?: "football-nfhs"; } -export interface CreateDrillPageDetails { +export interface CreateDrillSetDetails { readonly id?: string; - readonly label: string; + readonly number: number; + readonly suffix?: string; + readonly kind?: SetKind; readonly countsFromPrevious?: number; - readonly position: FieldPoint; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly facingDegrees?: number; } -export interface CreateDrillPageInput extends CreateDrillPageDetails { +export interface CreateDrillSetInput extends CreateDrillSetDetails { readonly drillId: string; } -export interface UpdateDrillPageInput { - readonly label?: string; +export interface UpdateDrillSetInput { + readonly number?: number; + readonly suffix?: string | null; + readonly kind?: SetKind; readonly countsFromPrevious?: number; - readonly position?: FieldPoint; + readonly measureRange?: MeasureRange | null; + readonly position?: DrillGridPoint; + readonly facingDegrees?: number | null; } +/** @deprecated Use CreateDrillSetDetails. */ +export type CreateDrillPageDetails = CreateDrillSetDetails; +/** @deprecated Use CreateDrillSetInput. */ +export type CreateDrillPageInput = CreateDrillSetInput; +/** @deprecated Use UpdateDrillSetInput. */ +export type UpdateDrillPageInput = UpdateDrillSetInput; + export interface DrillRepositoryFactories { readonly idFactory?: () => string; readonly timeFactory?: () => number; @@ -49,27 +67,46 @@ export interface DrillRepository { deleteDrill(id: string): Promise; setActiveDrill(id: string | null): Promise; - listPages(drillId: string): Promise; - getPage(id: string): Promise; - createPage(input: CreateDrillPageInput): Promise; - updatePage(id: string, input: UpdateDrillPageInput): Promise; + listSets(drillId: string): Promise; + getSet(id: string): Promise; + createSet(input: CreateDrillSetInput): Promise; + updateSet(id: string, input: UpdateDrillSetInput): Promise; + deleteSet(id: string): Promise; + insertSet( + drillId: string, + ordinal: number, + details: CreateDrillSetDetails, + ): Promise; + reorderSets( + drillId: string, + orderedSetIds: readonly (string | { readonly id: string })[], + ): Promise; + setSelectedDrillSet(id: string | null): Promise; + + /** @deprecated Compatibility aliases; new callers should use set methods. */ + listPages(drillId: string): Promise; + getPage(id: string): Promise; + createPage(input: CreateDrillSetInput): Promise; + updatePage(id: string, input: UpdateDrillSetInput): Promise; deletePage(id: string): Promise; insertPage( drillId: string, ordinal: number, - details: CreateDrillPageDetails, - ): Promise; + details: CreateDrillSetDetails, + ): Promise; reorderPages( drillId: string, - orderedPageIds: readonly (string | { readonly id: string })[], - ): Promise; + orderedSetIds: readonly (string | { readonly id: string })[], + ): Promise; setSelectedDrillPage(id: string | null): Promise; } export type DrillRepositoryErrorCode = | "DRILL_NOT_FOUND" + | "SET_NOT_FOUND" | "PAGE_NOT_FOUND" | "INVALID_INPUT" + | "INVALID_SET_ORDER" | "INVALID_PAGE_ORDER" | "INVALID_SELECTION"; @@ -83,15 +120,6 @@ export class DrillRepositoryError extends Error { } } -/** - * SQLite-backed drill storage. All values crossing this boundary are - * validated before they are bound to SQL, and every multi-row ordinal change - * is enclosed in one SQLite transaction. - * - * As with the settings repository, ordinary parameterized `runAsync` is used - * instead of hand-managed prepared statements. Expo SQLite prepares, - * executes, and finalizes each parameterized run for us. - */ export class SqliteDrillRepository implements DrillRepository { private readonly idFactory: () => string; private readonly timeFactory: () => number; @@ -108,7 +136,7 @@ export class SqliteDrillRepository implements DrillRepository { async listDrills(): Promise { const rows = await this.db.getAllAsync( - `SELECT id, name, created_at, updated_at + `SELECT id, name, field_preset, created_at, updated_at FROM ${DRILLS_TABLE} ORDER BY created_at ASC, id ASC`, ); @@ -118,7 +146,7 @@ export class SqliteDrillRepository implements DrillRepository { async getDrill(id: string): Promise { const drillId = assertId(id, "Drill id"); const row = await this.db.getFirstAsync( - `SELECT id, name, created_at, updated_at + `SELECT id, name, field_preset, created_at, updated_at FROM ${DRILLS_TABLE} WHERE id = ?`, [drillId], @@ -130,22 +158,25 @@ export class SqliteDrillRepository implements DrillRepository { const input: CreateDrillInput = typeof inputOrName === "string" ? { name: inputOrName } : inputOrName; const name = assertText(input.name, "Drill name"); - const generatedAt = this.timeFactory(); - const created = assertTimestamp( - input.createdAt ?? generatedAt, + const createdAt = assertTimestamp( + input.createdAt ?? this.timeFactory(), "Drill createdAt", ); - const updated = assertTimestamp( - input.updatedAt ?? created, + const updatedAt = assertTimestamp( + input.updatedAt ?? createdAt, "Drill updatedAt", ); const id = assertId(input.id ?? this.idFactory(), "Drill id"); + const fieldPreset = input.fieldPreset ?? "football-nfhs"; + if (fieldPreset !== "football-nfhs") { + throw invalidInput("The mobile MVP currently supports the NFHS field preset."); + } await this.db.runAsync( `INSERT INTO ${DRILLS_TABLE} - (id, name, created_at, updated_at) - VALUES (?, ?, ?, ?)`, - [id, name, created, updated], + (id, name, field_preset, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + [id, name, fieldPreset, createdAt, updatedAt], ); return requireValue(await this.getDrill(id), "drill", id); } @@ -163,9 +194,7 @@ export class SqliteDrillRepository implements DrillRepository { ); await this.requireDrill(id); await this.db.runAsync( - `UPDATE ${DRILLS_TABLE} - SET name = ?, updated_at = ? - WHERE id = ?`, + `UPDATE ${DRILLS_TABLE} SET name = ?, updated_at = ? WHERE id = ?`, [nextName, nextUpdatedAt, id], ); return requireValue(await this.getDrill(id), "drill", id); @@ -173,12 +202,7 @@ export class SqliteDrillRepository implements DrillRepository { async deleteDrill(id: string): Promise { const drillId = assertId(id, "Drill id"); - await this.db.withTransactionAsync(async () => { - // Foreign keys clear app_settings pointers and cascade drill pages. - await this.db.runAsync(`DELETE FROM ${DRILLS_TABLE} WHERE id = ?`, [ - drillId, - ]); - }); + await this.db.runAsync(`DELETE FROM ${DRILLS_TABLE} WHERE id = ?`, [drillId]); } async setActiveDrill(id: string | null): Promise { @@ -189,179 +213,152 @@ export class SqliteDrillRepository implements DrillRepository { const current = await this.db.getFirstAsync<{ active_drill_id: SqlValue | undefined; }>( - `SELECT active_drill_id - FROM ${APP_SETTINGS_TABLE} - WHERE singleton_id = ?`, + `SELECT active_drill_id FROM ${APP_SETTINGS_TABLE} WHERE singleton_id = ?`, [1], ); const currentActive = nullableIdFromSql(current?.active_drill_id); - if (currentActive === activeDrillId && activeDrillId !== null) { - await this.db.runAsync( - `UPDATE ${APP_SETTINGS_TABLE} - SET active_drill_id = ? - WHERE singleton_id = ?`, - [activeDrillId, 1], - ); - return; - } - // Changing the active drill, including clearing it, clears the page - // selection in the same transaction as the active pointer update. await this.db.runAsync( `UPDATE ${APP_SETTINGS_TABLE} - SET active_drill_id = ?, selected_drill_page_id = NULL + SET active_drill_id = ?, + selected_drill_page_id = CASE + WHEN active_drill_id IS ? THEN selected_drill_page_id + ELSE NULL + END WHERE singleton_id = ?`, - [activeDrillId, 1], + [activeDrillId, activeDrillId, 1], ); + if (currentActive !== activeDrillId && activeDrillId === null) { + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET selected_drill_page_id = NULL WHERE singleton_id = ?`, + [1], + ); + } }); return await this.settingsRepository.load(); } - async listPages(drillId: string): Promise { + async listSets(drillId: string): Promise { const parentId = assertId(drillId, "Drill id"); const rows = await this.db.getAllAsync( - `SELECT id, drill_id, ordinal, label, counts_from_previous, - x_meters, y_meters - FROM ${DRILL_PAGES_TABLE} - WHERE drill_id = ? - ORDER BY ordinal ASC, id ASC`, + `${SET_SELECT} WHERE drill_id = ? ORDER BY ordinal ASC, id ASC`, [parentId], ); - return rows.map(toPage); + return rows.map(toSet); } - async getPage(id: string): Promise { - const pageId = assertId(id, "Drill page id"); + async getSet(id: string): Promise { + const setId = assertId(id, "Drill set id"); const row = await this.db.getFirstAsync( - `SELECT id, drill_id, ordinal, label, counts_from_previous, - x_meters, y_meters - FROM ${DRILL_PAGES_TABLE} - WHERE id = ?`, - [pageId], + `${SET_SELECT} WHERE id = ?`, + [setId], ); - return row ? toPage(row) : undefined; + return row ? toSet(row) : undefined; } - async createPage(input: CreateDrillPageInput): Promise { - const normalized = normalizePage(input); - const createdId = assertId( - normalized.id ?? this.idFactory(), - "Drill page id", - ); - + async createSet(input: CreateDrillSetInput): Promise { + const normalized = normalizeCreateSet(input); + const createdId = assertId(normalized.id ?? this.idFactory(), "Drill set id"); await this.db.withTransactionAsync(async () => { await this.requireDrill(normalized.drillId); - const count = await this.pageCount(normalized.drillId); - await this.insertPageRow({ - ...normalized, - id: createdId, - ordinal: count, - }); + const count = await this.setCount(normalized.drillId); + if (count === 0 && normalized.countsFromPrevious !== 0) { + throw invalidInput("The first set must have zero counts from previous."); + } + await this.insertSetRow({ ...normalized, id: createdId, ordinal: count }); + await this.validateSetStructure(normalized.drillId); }); - return requireValue(await this.getPage(createdId), "drill page", createdId); - } - - async updatePage( - pageId: string, - changes: UpdateDrillPageInput, - ): Promise { - const id = assertId(pageId, "Drill page id"); - const current = await this.getPage(id); - if (!current) throw pageNotFound(id); - - const assignments: string[] = []; - const params: (string | number | null)[] = []; - if (changes.label !== undefined) { - assignments.push("label = ?"); - params.push(assertText(changes.label, "Drill page label")); - } - if (changes.countsFromPrevious !== undefined) { - assignments.push("counts_from_previous = ?"); - params.push( - assertCount(changes.countsFromPrevious, "countsFromPrevious"), - ); - } - if (changes.position !== undefined) { - const position = assertPosition(changes.position); - assignments.push("x_meters = ?", "y_meters = ?"); - params.push(position.xMeters, position.yMeters); + return requireValue(await this.getSet(createdId), "drill set", createdId); + } + + async updateSet(idValue: string, changes: UpdateDrillSetInput): Promise { + const id = assertId(idValue, "Drill set id"); + const current = await this.getSet(id); + if (!current) throw setNotFound(id); + + const next = normalizeExistingSet(current, changes); + if (next.ordinal === 0 && next.countsFromPrevious !== 0) { + throw invalidInput("The first set must have zero counts from previous."); } - if (!assignments.length) return current; - params.push(id); - await this.db.runAsync( - `UPDATE ${DRILL_PAGES_TABLE} - SET ${assignments.join(", ")} - WHERE id = ?`, - params, - ); - return requireValue(await this.getPage(id), "drill page", id); + await this.db.withTransactionAsync(async () => { + await this.updateSetRow(next); + await this.validateSetStructure(current.drillId); + }); + return requireValue(await this.getSet(id), "drill set", id); } - async deletePage(id: string): Promise { - const pageId = assertId(id, "Drill page id"); + async deleteSet(idValue: string): Promise { + const id = assertId(idValue, "Drill set id"); await this.db.withTransactionAsync(async () => { - const page = await this.getPage(pageId); - if (!page) return; - await this.db.runAsync(`DELETE FROM ${DRILL_PAGES_TABLE} WHERE id = ?`, [ - pageId, - ]); - // The deleted ordinal is now a gap; moving higher ordinals down cannot - // collide with the rows that remain. + const set = await this.getSet(id); + if (!set) return; + await this.db.runAsync(`DELETE FROM ${DRILL_SETS_TABLE} WHERE id = ?`, [id]); await this.db.runAsync( - `UPDATE ${DRILL_PAGES_TABLE} + `UPDATE ${DRILL_SETS_TABLE} SET ordinal = ordinal - 1 WHERE drill_id = ? AND ordinal > ?`, - [page.drillId, page.ordinal], + [set.drillId, set.ordinal], ); + const nextFirst = await this.db.getFirstAsync<{ id: string }>( + `SELECT id FROM ${DRILL_SETS_TABLE} + WHERE drill_id = ? ORDER BY ordinal ASC LIMIT 1`, + [set.drillId], + ); + if (nextFirst) { + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} + SET counts_from_previous = 0 WHERE id = ?`, + [nextFirst.id], + ); + } + await this.validateSetStructure(set.drillId); }); } - async insertPage( + async insertSet( drillId: string, ordinalValue: number, - details: CreateDrillPageDetails, - ): Promise { - const input: CreateDrillPageInput = { ...details, drillId }; - const normalized = normalizePage(input); - const ordinal = assertOrdinal(ordinalValue, "Page ordinal"); - const id = assertId(normalized.id ?? this.idFactory(), "Drill page id"); - + details: CreateDrillSetDetails, + ): Promise { + const normalized = normalizeCreateSet({ ...details, drillId }); + const ordinal = assertOrdinal(ordinalValue, "Set ordinal"); + const id = assertId(normalized.id ?? this.idFactory(), "Drill set id"); await this.db.withTransactionAsync(async () => { await this.requireDrill(normalized.drillId); - const count = await this.pageCount(normalized.drillId); + const count = await this.setCount(normalized.drillId); if (ordinal > count) { - throw new RangeError( - `Page ordinal must be between 0 and ${count} when inserting.`, - ); + throw invalidInput(`Set ordinal must be between 0 and ${count} when inserting.`); } - if (count > 0) - await this.shiftPagesForInsertion(normalized.drillId, count, ordinal); - await this.insertPageRow({ ...normalized, id, ordinal }); + if (ordinal === 0 && normalized.countsFromPrevious !== 0) { + throw invalidInput("The first set must have zero counts from previous."); + } + if (count > 0) await this.shiftSetsForInsertion(normalized.drillId, count, ordinal); + await this.insertSetRow({ ...normalized, id, ordinal }); + await this.validateSetStructure(normalized.drillId); }); - return requireValue(await this.getPage(id), "drill page", id); + return requireValue(await this.getSet(id), "drill set", id); } - async reorderPages( + async reorderSets( drillId: string, - orderedPageIds: readonly (string | { readonly id: string })[], - ): Promise { + orderedSetIds: readonly (string | { readonly id: string })[], + ): Promise { const parentId = assertId(drillId, "Drill id"); - const ids = orderedPageIds.map((value) => - assertId(typeof value === "string" ? value : value.id, "Drill page id"), + const ids = orderedSetIds.map((value) => + assertId(typeof value === "string" ? value : value.id, "Drill set id"), ); if (new Set(ids).size !== ids.length) { throw new DrillRepositoryError( - "INVALID_PAGE_ORDER", - "A page may appear only once in a reorder operation.", + "INVALID_SET_ORDER", + "A set may appear only once in a reorder operation.", ); } await this.db.withTransactionAsync(async () => { const rows = await this.db.getAllAsync<{ id: string }>( - `SELECT id - FROM ${DRILL_PAGES_TABLE} - WHERE drill_id = ? - ORDER BY ordinal ASC, id ASC`, + `SELECT id FROM ${DRILL_SETS_TABLE} + WHERE drill_id = ? ORDER BY ordinal ASC, id ASC`, [parentId], ); const existingIds = rows.map((row) => row.id); @@ -370,50 +367,47 @@ export class SqliteDrillRepository implements DrillRepository { existingIds.some((id) => !ids.includes(id)) ) { throw new DrillRepositoryError( - "INVALID_PAGE_ORDER", - "A reorder must contain every page in the drill exactly once.", + "INVALID_SET_ORDER", + "A reorder must contain every set in the drill exactly once.", ); } - if (ids.length > 0) { const offset = ids.length + 1; await this.db.runAsync( - `UPDATE ${DRILL_PAGES_TABLE} - SET ordinal = ordinal + ? - WHERE drill_id = ?`, + `UPDATE ${DRILL_SETS_TABLE} SET ordinal = ordinal + ? WHERE drill_id = ?`, [offset, parentId], ); - for (const [ordinal, pageId] of ids.entries()) { + for (const [ordinal, setId] of ids.entries()) { await this.db.runAsync( - `UPDATE ${DRILL_PAGES_TABLE} - SET ordinal = ? - WHERE id = ? AND drill_id = ?`, - [ordinal, pageId, parentId], + `UPDATE ${DRILL_SETS_TABLE} SET ordinal = ? WHERE id = ? AND drill_id = ?`, + [ordinal, setId, parentId], ); } + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} SET counts_from_previous = 0 + WHERE drill_id = ? AND ordinal = 0`, + [parentId], + ); } + await this.validateSetStructure(parentId); }); - return await this.listPages(parentId); + return await this.listSets(parentId); } - async setSelectedDrillPage(id: string | null): Promise { - const selectedPageId = nullableId(id, "Selected drill page id"); + async setSelectedDrillSet(id: string | null): Promise { + const selectedSetId = nullableId(id, "Selected drill set id"); await this.db.withTransactionAsync(async () => { await this.ensureSettingsRow(); const settings = await this.db.getFirstAsync<{ active_drill_id: SqlValue | undefined; }>( - `SELECT active_drill_id - FROM ${APP_SETTINGS_TABLE} - WHERE singleton_id = ?`, + `SELECT active_drill_id FROM ${APP_SETTINGS_TABLE} WHERE singleton_id = ?`, [1], ); const activeDrillId = nullableIdFromSql(settings?.active_drill_id); - if (selectedPageId === null) { + if (selectedSetId === null) { await this.db.runAsync( - `UPDATE ${APP_SETTINGS_TABLE} - SET selected_drill_page_id = NULL - WHERE singleton_id = ?`, + `UPDATE ${APP_SETTINGS_TABLE} SET selected_drill_page_id = NULL WHERE singleton_id = ?`, [1], ); return; @@ -421,97 +415,129 @@ export class SqliteDrillRepository implements DrillRepository { if (activeDrillId === null) { throw new DrillRepositoryError( "INVALID_SELECTION", - "A drill page cannot be selected without an active drill.", + "A drill set cannot be selected without an active drill.", ); } - const page = await this.db.getFirstAsync<{ drill_id: string }>( - `SELECT drill_id - FROM ${DRILL_PAGES_TABLE} - WHERE id = ?`, - [selectedPageId], + const set = await this.db.getFirstAsync<{ drill_id: string }>( + `SELECT drill_id FROM ${DRILL_SETS_TABLE} WHERE id = ?`, + [selectedSetId], ); - if (!page || page.drill_id !== activeDrillId) { + if (!set || set.drill_id !== activeDrillId) { throw new DrillRepositoryError( "INVALID_SELECTION", - "The selected page must belong to the active drill.", + "The selected set must belong to the active drill.", ); } await this.db.runAsync( - `UPDATE ${APP_SETTINGS_TABLE} - SET selected_drill_page_id = ? - WHERE singleton_id = ?`, - [selectedPageId, 1], + `UPDATE ${APP_SETTINGS_TABLE} SET selected_drill_page_id = ? WHERE singleton_id = ?`, + [selectedSetId, 1], ); }); return await this.settingsRepository.load(); } + // Compatibility aliases. + listPages(drillId: string) { return this.listSets(drillId); } + getPage(id: string) { return this.getSet(id); } + createPage(input: CreateDrillSetInput) { return this.createSet(input); } + updatePage(id: string, input: UpdateDrillSetInput) { return this.updateSet(id, input); } + deletePage(id: string) { return this.deleteSet(id); } + insertPage(drillId: string, ordinal: number, details: CreateDrillSetDetails) { + return this.insertSet(drillId, ordinal, details); + } + reorderPages( + drillId: string, + orderedSetIds: readonly (string | { readonly id: string })[], + ) { + return this.reorderSets(drillId, orderedSetIds); + } + setSelectedDrillPage(id: string | null) { return this.setSelectedDrillSet(id); } + private async requireDrill(id: string): Promise { const drill = await this.getDrill(id); if (!drill) throw drillNotFound(id); return drill; } - private async pageCount(drillId: string): Promise { - const row = await this.db.getFirstAsync<{ - page_count: SqlValue | undefined; - }>( - `SELECT COUNT(*) AS page_count - FROM ${DRILL_PAGES_TABLE} - WHERE drill_id = ?`, + private async setCount(drillId: string): Promise { + const row = await this.db.getFirstAsync<{ set_count: SqlValue | undefined }>( + `SELECT COUNT(*) AS set_count FROM ${DRILL_SETS_TABLE} WHERE drill_id = ?`, [drillId], ); - const count = Number(row?.page_count ?? 0); + const count = Number(row?.set_count ?? 0); if (!Number.isInteger(count) || count < 0) { - throw new DrillRepositoryError( - "INVALID_INPUT", - "The persisted page count is invalid.", - ); + throw invalidInput("The persisted set count is invalid."); } return count; } - private async insertPageRow(page: { - readonly id: string; - readonly drillId: string; - readonly ordinal: number; - readonly label: string; - readonly countsFromPrevious: number; - readonly position: FieldPoint; - }): Promise { + private async insertSetRow(set: NormalizedCreateSet & { id: string; ordinal: number }) { + const physical = drillGridPointToFieldPoint(set.position); + const label = formatSetName(set); + await this.db.runAsync( + `INSERT INTO ${DRILL_SETS_TABLE} + (id, drill_id, ordinal, set_number, set_suffix, set_kind, + counts_from_previous, measure_start, measure_end, + x_steps, y_steps, facing_degrees, label, x_meters, y_meters) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + set.id, + set.drillId, + set.ordinal, + set.number, + set.suffix ?? null, + set.kind, + set.countsFromPrevious, + set.measureRange?.start ?? null, + set.measureRange?.end ?? null, + set.position.xSteps, + set.position.ySteps, + set.facingDegrees ?? null, + label, + physical.xMeters, + physical.yMeters, + ], + ); + } + + private async updateSetRow(set: DrillSet): Promise { + const physical = drillGridPointToFieldPoint(set.position); await this.db.runAsync( - `INSERT INTO ${DRILL_PAGES_TABLE} - (id, drill_id, ordinal, label, counts_from_previous, x_meters, y_meters) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `UPDATE ${DRILL_SETS_TABLE} + SET set_number = ?, set_suffix = ?, set_kind = ?, counts_from_previous = ?, + measure_start = ?, measure_end = ?, x_steps = ?, y_steps = ?, + facing_degrees = ?, label = ?, x_meters = ?, y_meters = ? + WHERE id = ?`, [ - page.id, - page.drillId, - page.ordinal, - page.label, - page.countsFromPrevious, - page.position.xMeters, - page.position.yMeters, + set.number, + set.suffix ?? null, + set.kind, + set.countsFromPrevious, + set.measureRange?.start ?? null, + set.measureRange?.end ?? null, + set.position.xSteps, + set.position.ySteps, + set.facingDegrees ?? null, + formatSetName(set), + physical.xMeters, + physical.yMeters, + set.id, ], ); - return page.id; } - private async shiftPagesForInsertion( + private async shiftSetsForInsertion( drillId: string, count: number, insertionOrdinal: number, ): Promise { const offset = count + 1; await this.db.runAsync( - `UPDATE ${DRILL_PAGES_TABLE} - SET ordinal = ordinal + ? - WHERE drill_id = ?`, + `UPDATE ${DRILL_SETS_TABLE} SET ordinal = ordinal + ? WHERE drill_id = ?`, [offset, drillId], ); - // The temporary offset prevents SQLite's unique (drill_id, ordinal) - // constraint from observing an intermediate collision. await this.db.runAsync( - `UPDATE ${DRILL_PAGES_TABLE} + `UPDATE ${DRILL_SETS_TABLE} SET ordinal = CASE WHEN ordinal >= ? THEN ordinal - ? + 1 ELSE ordinal - ? @@ -521,57 +547,167 @@ export class SqliteDrillRepository implements DrillRepository { ); } + private async validateSetStructure(drillId: string): Promise { + const sets = await this.listSets(drillId); + const primaryNumbers = new Set(); + const identities = new Set(); + for (const [index, set] of sets.entries()) { + if (set.ordinal !== index) { + throw invalidInput("Persisted set ordinals must be contiguous from zero."); + } + if (index === 0 && set.countsFromPrevious !== 0) { + throw invalidInput("The first set must have zero counts from previous."); + } + const identity = `${set.number}|${set.suffix ?? ""}`; + if (identities.has(identity)) { + throw invalidInput(`Set ${formatSetName(set)} already exists in this drill.`); + } + identities.add(identity); + if (set.kind === "set") { + if (primaryNumbers.has(set.number)) { + throw invalidInput(`Primary set ${set.number} may appear only once.`); + } + primaryNumbers.add(set.number); + } + } + for (const set of sets) { + if (set.kind === "subset" && !primaryNumbers.has(set.number)) { + throw invalidInput( + `Subset ${formatSetName(set)} requires primary set ${set.number}.`, + ); + } + } + } + private async ensureSettingsRow(): Promise { await this.db.runAsync( - `INSERT OR IGNORE INTO ${APP_SETTINGS_TABLE} (singleton_id) - VALUES (?)`, + `INSERT OR IGNORE INTO ${APP_SETTINGS_TABLE} (singleton_id) VALUES (?)`, [1], ); } } -function normalizePage(input: CreateDrillPageInput): { +const SET_SELECT = `SELECT id, drill_id, ordinal, set_number, set_suffix, set_kind, + counts_from_previous, measure_start, measure_end, x_steps, y_steps, facing_degrees + FROM ${DRILL_SETS_TABLE}`; + +interface NormalizedCreateSet { readonly id?: string; readonly drillId: string; - readonly label: string; + readonly number: number; + readonly suffix?: string; + readonly kind: SetKind; readonly countsFromPrevious: number; - readonly position: FieldPoint; -} { + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly facingDegrees?: number; +} + +function normalizeCreateSet(input: CreateDrillSetInput): NormalizedCreateSet { + const kind = input.kind ?? (input.suffix ? "subset" : "set"); + const suffix = normalizeSuffix(input.suffix, kind); return { - ...(input.id === undefined - ? {} - : { id: assertId(input.id, "Drill page id") }), + ...(input.id === undefined ? {} : { id: assertId(input.id, "Drill set id") }), drillId: assertId(input.drillId, "Drill id"), - label: assertText(input.label, "Drill page label"), - countsFromPrevious: assertCount( + number: assertNonNegativeInteger(input.number, "Set number"), + ...(suffix === undefined ? {} : { suffix }), + kind, + countsFromPrevious: assertNonNegativeInteger( input.countsFromPrevious ?? 0, "countsFromPrevious", ), - position: assertPosition(input.position), + ...(input.measureRange === undefined + ? {} + : { measureRange: assertMeasureRange(input.measureRange) }), + position: assertGridPoint(input.position), + ...(input.facingDegrees === undefined + ? {} + : { facingDegrees: assertFacing(input.facingDegrees) }), }; } -function assertPosition(position: FieldPoint): FieldPoint { - assertFiniteFieldPoint(position, "Drill page position"); - return { xMeters: position.xMeters, yMeters: position.yMeters }; +function normalizeExistingSet( + current: DrillSet, + changes: UpdateDrillSetInput, +): DrillSet { + const kind = changes.kind ?? current.kind; + const rawSuffix = changes.suffix === undefined ? current.suffix : changes.suffix ?? undefined; + const suffix = normalizeSuffix(rawSuffix, kind); + const measureRange = + changes.measureRange === undefined + ? current.measureRange + : changes.measureRange === null + ? undefined + : assertMeasureRange(changes.measureRange); + const facingDegrees = + changes.facingDegrees === undefined + ? current.facingDegrees + : changes.facingDegrees === null + ? undefined + : assertFacing(changes.facingDegrees); + return { + ...current, + number: + changes.number === undefined + ? current.number + : assertNonNegativeInteger(changes.number, "Set number"), + kind, + ...(suffix === undefined ? { suffix: undefined } : { suffix }), + countsFromPrevious: + changes.countsFromPrevious === undefined + ? current.countsFromPrevious + : assertNonNegativeInteger(changes.countsFromPrevious, "countsFromPrevious"), + ...(measureRange === undefined ? { measureRange: undefined } : { measureRange }), + position: + changes.position === undefined ? current.position : assertGridPoint(changes.position), + ...(facingDegrees === undefined ? { facingDegrees: undefined } : { facingDegrees }), + }; +} + +function normalizeSuffix(value: string | undefined, kind: SetKind): string | undefined { + if (kind === "set") { + if (value !== undefined && value.trim().length > 0) { + throw invalidInput("Primary sets cannot have a suffix."); + } + return undefined; + } + if (typeof value !== "string" || !/^(?:[A-Z]|\.[0-9]+)$/.test(value.trim())) { + throw invalidInput("A subset suffix must be one capital letter or a decimal such as .5."); + } + return value.trim(); +} + +function assertGridPoint(position: DrillGridPoint): DrillGridPoint { + if (!position || !Number.isFinite(position.xSteps) || !Number.isFinite(position.ySteps)) { + throw invalidInput("Drill set position must contain finite xSteps and ySteps."); + } + return { xSteps: position.xSteps, ySteps: position.ySteps }; +} + +function assertMeasureRange(value: MeasureRange): MeasureRange { + const start = assertNonNegativeInteger(value.start, "Measure start"); + const end = assertNonNegativeInteger(value.end, "Measure end"); + if (end < start) throw invalidInput("Measure end must be at or after measure start."); + return { start, end }; +} + +function assertFacing(value: number): number { + if (!Number.isFinite(value) || value < 0 || value >= 360) { + throw invalidInput("Facing must be at least 0 and less than 360 degrees."); + } + return value; } function assertText(value: unknown, name: string): string { if (typeof value !== "string" || value.trim().length === 0) { - throw new DrillRepositoryError( - "INVALID_INPUT", - `${name} must be a non-empty string.`, - ); + throw invalidInput(`${name} must be a non-empty string.`); } return value.trim(); } function assertId(value: unknown, name: string): string { if (typeof value !== "string" || value.trim().length === 0) { - throw new DrillRepositoryError( - "INVALID_INPUT", - `${name} must be a non-empty string.`, - ); + throw invalidInput(`${name} must be a non-empty string.`); } return value.trim(); } @@ -583,39 +719,26 @@ function nullableId(value: string | null, name: string): string | null { function assertTimestamp(value: unknown, name: string): number { if (typeof value !== "number" || !Number.isFinite(value)) { - throw new DrillRepositoryError( - "INVALID_INPUT", - `${name} must be a finite number.`, - ); - } - return value; -} - -function assertCount(value: unknown, name: string): number { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - throw new DrillRepositoryError( - "INVALID_INPUT", - `${name} must be a finite non-negative number.`, - ); + throw invalidInput(`${name} must be a finite number.`); } return value; } -function assertOrdinal(value: unknown, name: string): number { +function assertNonNegativeInteger(value: unknown, name: string): number { if ( typeof value !== "number" || - !Number.isInteger(value) || - !Number.isFinite(value) || + !Number.isSafeInteger(value) || value < 0 ) { - throw new DrillRepositoryError( - "INVALID_INPUT", - `${name} must be a non-negative integer.`, - ); + throw invalidInput(`${name} must be a non-negative integer.`); } return value; } +function assertOrdinal(value: unknown, name: string): number { + return assertNonNegativeInteger(value, name); +} + function nullableIdFromSql(value: SqlValue | undefined): string | null { if (typeof value !== "string") return null; const normalized = value.trim(); @@ -623,34 +746,53 @@ function nullableIdFromSql(value: SqlValue | undefined): string | null { } function toDrill(row: Row): Drill { + const fieldPreset = rowText(row.field_preset, "drill field_preset"); + if (fieldPreset !== "football-nfhs") { + throw new MobileRowError(`Unsupported mobile field preset ${fieldPreset}.`); + } return { id: rowText(row.id, "drill id"), name: rowText(row.name, "drill name"), + fieldPreset, createdAt: rowNumber(row.created_at, "drill created_at"), updatedAt: rowNumber(row.updated_at, "drill updated_at"), }; } -function toPage(row: Row): DrillPage { - const xMeters = rowNumber(row.x_meters, "drill page x_meters"); - const yMeters = rowNumber(row.y_meters, "drill page y_meters"); +function toSet(row: Row): DrillSet { + const kind = rowText(row.set_kind, "drill set kind"); + if (kind !== "set" && kind !== "subset") { + throw new MobileRowError(`Invalid drill set kind ${kind}.`); + } + const suffixValue = row.set_suffix; + const suffix = typeof suffixValue === "string" ? suffixValue : undefined; + const measureStart = rowNullableNumber(row.measure_start, "measure_start"); + const measureEnd = rowNullableNumber(row.measure_end, "measure_end"); + const facingDegrees = rowNullableNumber(row.facing_degrees, "facing_degrees"); return { - id: rowText(row.id, "drill page id"), - drillId: rowText(row.drill_id, "drill page drill_id"), - ordinal: rowNumber(row.ordinal, "drill page ordinal"), - label: rowText(row.label, "drill page label"), - countsFromPrevious: rowNumber( + id: rowText(row.id, "drill set id"), + drillId: rowText(row.drill_id, "drill set drill_id"), + ordinal: rowInteger(row.ordinal, "drill set ordinal"), + number: rowInteger(row.set_number, "drill set number"), + ...(suffix === undefined ? {} : { suffix }), + kind, + countsFromPrevious: rowInteger( row.counts_from_previous, - "drill page counts_from_previous", + "drill set counts_from_previous", ), - position: { xMeters, yMeters }, + ...(measureStart === null || measureEnd === null + ? {} + : { measureRange: { start: measureStart, end: measureEnd } }), + position: { + xSteps: rowNumber(row.x_steps, "drill set x_steps"), + ySteps: rowNumber(row.y_steps, "drill set y_steps"), + }, + ...(facingDegrees === null ? {} : { facingDegrees }), }; } function rowText(value: SqlValue | undefined, name: string): string { - if (typeof value !== "string") { - throw new MobileRowError(`${name} is not a string.`); - } + if (typeof value !== "string") throw new MobileRowError(`${name} is not a string.`); return value; } @@ -665,6 +807,17 @@ function rowNumber(value: SqlValue | undefined, name: string): number { return number; } +function rowInteger(value: SqlValue | undefined, name: string): number { + const number = rowNumber(value, name); + if (!Number.isSafeInteger(number)) throw new MobileRowError(`${name} is not an integer.`); + return number; +} + +function rowNullableNumber(value: SqlValue | undefined, name: string): number | null { + if (value === null || value === undefined) return null; + return rowNumber(value, name); +} + class MobileRowError extends Error { constructor(message: string) { super(message); @@ -677,24 +830,20 @@ function requireValue(value: T | undefined, entity: string, id: string): T { throw new MobileRowError(`The persisted ${entity} ${id} could not be read.`); } +function invalidInput(message: string): DrillRepositoryError { + return new DrillRepositoryError("INVALID_INPUT", message); +} + function drillNotFound(id: string): DrillRepositoryError { - return new DrillRepositoryError( - "DRILL_NOT_FOUND", - `Drill ${id} was not found.`, - ); + return new DrillRepositoryError("DRILL_NOT_FOUND", `Drill ${id} was not found.`); } -function pageNotFound(id: string): DrillRepositoryError { - return new DrillRepositoryError( - "PAGE_NOT_FOUND", - `Drill page ${id} was not found.`, - ); +function setNotFound(id: string): DrillRepositoryError { + return new DrillRepositoryError("SET_NOT_FOUND", `Drill set ${id} was not found.`); } function defaultIdFactory(): string { const cryptoApi = globalThis.crypto; if (cryptoApi?.randomUUID) return cryptoApi.randomUUID(); - return `mobile-${Date.now().toString(36)}-${Math.random() - .toString(36) - .slice(2, 12)}`; + return `mobile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; } diff --git a/packages/mobile/src/drill/__tests__/analysis.test.ts b/packages/mobile/src/drill/__tests__/analysis.test.ts index 3df042ee..19efc66c 100644 --- a/packages/mobile/src/drill/__tests__/analysis.test.ts +++ b/packages/mobile/src/drill/__tests__/analysis.test.ts @@ -1,29 +1,30 @@ import { analyzeDrillTransition, analyzeTransition, - type DrillPage, + type DrillSet, } from "../index"; -import { standardStepsToMeters, yardsToMeters } from "../../field"; -function page( +function set( id: string, - xYards: number, + xSteps: number, countsFromPrevious: number, - yMeters = 0, -): DrillPage { + ySteps = 0, +): DrillSet { + const ordinal = Number(id); return { id, drillId: "drill-1", - ordinal: Number(id), - label: id, + ordinal, + number: ordinal, + kind: "set", countsFromPrevious, - position: { xMeters: yardsToMeters(xYards), yMeters }, + position: { xSteps, ySteps }, }; } describe("drill transition analysis", () => { - test("omits derived rates for the first page", () => { - expect(analyzeDrillTransition(undefined, page("1", 10, 0))).toEqual({ + test("omits derived rates for the first set", () => { + expect(analyzeDrillTransition(undefined, set("1", -64, 0))).toEqual({ distanceSteps: 0, isHalt: false, yardLineCrossingCounts: [], @@ -31,7 +32,7 @@ describe("drill transition analysis", () => { }); test("omits step size and crossing counts for zero counts", () => { - const analysis = analyzeDrillTransition(page("1", 10, 0), page("2", 20, 0)); + const analysis = analyzeDrillTransition(set("1", -64, 0), set("2", -48, 0)); expect(analysis.distanceSteps).toBe(16); expect(analysis.stepSizeToFive).toBeUndefined(); expect(analysis.yardLineCrossingCounts).toEqual([]); @@ -39,49 +40,49 @@ describe("drill transition analysis", () => { test("recognizes a same-position positive-count halt", () => { const analysis = analyzeDrillTransition( - page("1", 40, 0), - page("2", 40, 16), + set("1", -16, 0), + set("2", -16, 16), ); expect(analysis).toMatchObject({ distanceSteps: 0, isHalt: true }); expect(analysis.stepSizeToFive).toBeUndefined(); }); test.each([ - [standardStepsToMeters(8), 8, 8], - [standardStepsToMeters(4.5), 8, 14.25], - [standardStepsToMeters(16), 13, 6.5], + [8, 8, 8], + [4.5, 8, 14.25], + [16, 13, 6.5], ])( - "derives %s meters over %s counts as %s-to-5", + "derives %s drill-grid steps over %s counts as %s-to-5", (distance, counts, expected) => { expect( analyzeTransition( - { xMeters: 0, yMeters: 0 }, - { xMeters: distance, yMeters: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: distance, ySteps: 0 }, counts, ).stepSizeToFive, ).toBe(expected); }, ); - test("returns the transition count at one and multiple line crossings", () => { + test("returns the transition count at one and multiple five-yard crossings", () => { expect( analyzeTransition( - { xMeters: yardsToMeters(10), yMeters: 0 }, - { xMeters: yardsToMeters(20), yMeters: 0 }, + { xSteps: -64, ySteps: 0 }, + { xSteps: -48, ySteps: 0 }, 8, ).yardLineCrossingCounts, ).toEqual([4]); expect( analyzeTransition( - { xMeters: yardsToMeters(10), yMeters: 0 }, - { xMeters: yardsToMeters(25), yMeters: 0 }, + { xSteps: -64, ySteps: 0 }, + { xSteps: -40, ySteps: 0 }, 16, ).yardLineCrossingCounts, ).toEqual([5.333333, 10.666667]); expect( analyzeTransition( - { xMeters: yardsToMeters(12.5), yMeters: 0 }, - { xMeters: yardsToMeters(22.5), yMeters: 0 }, + { xSteps: -60, ySteps: 0 }, + { xSteps: -44, ySteps: 0 }, 16, ).yardLineCrossingCounts, ).toEqual([4, 12]); @@ -90,8 +91,8 @@ describe("drill transition analysis", () => { test("returns crossing counts in time order for reverse movement", () => { expect( analyzeTransition( - { xMeters: yardsToMeters(25), yMeters: 0 }, - { xMeters: yardsToMeters(10), yMeters: 0 }, + { xSteps: -40, ySteps: 0 }, + { xSteps: -64, ySteps: 0 }, 16, ).yardLineCrossingCounts, ).toEqual([5.333333, 10.666667]); @@ -100,8 +101,8 @@ describe("drill transition analysis", () => { test("excludes exact start and end yard lines", () => { expect( analyzeTransition( - { xMeters: yardsToMeters(10), yMeters: 0 }, - { xMeters: yardsToMeters(15), yMeters: 0 }, + { xSteps: -64, ySteps: 0 }, + { xSteps: -56, ySteps: 0 }, 8, ).yardLineCrossingCounts, ).toEqual([]); @@ -110,16 +111,26 @@ describe("drill transition analysis", () => { test("cleans floating-point values near integer and half counts", () => { expect( analyzeTransition( - { xMeters: yardsToMeters(10), yMeters: 0 }, - { xMeters: yardsToMeters(20) + 1e-12, yMeters: 0 }, + { xSteps: -64, ySteps: 0 }, + { xSteps: -48 + 1e-12, ySteps: 0 }, 8, ).yardLineCrossingCounts, ).toEqual([4]); }); - test("keeps derived values off persisted page records", () => { - const current = page("2", 20, 8); - analyzeDrillTransition(page("1", 10, 0), current); + test("rejects fractional incoming counts", () => { + expect(() => + analyzeTransition( + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + 2.5, + ), + ).toThrow("integer"); + }); + + test("keeps derived values off persisted set records", () => { + const current = set("2", -48, 8); + analyzeDrillTransition(set("1", -64, 0), current); expect(current).not.toHaveProperty("stepSizeToFive"); expect(current).not.toHaveProperty("yardLineCrossingCounts"); }); diff --git a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts index a8961331..8fef63d7 100644 --- a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts +++ b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts @@ -2,9 +2,9 @@ import type { SQLiteDatabase } from "expo-sqlite"; import { SqliteDrillRepository } from "../SqliteDrillRepository"; describe("SqliteDrillRepository", () => { - test("uses stable factories and deterministic drill/page ordering", async () => { + test("uses stable factories and deterministic drill/set ordering", async () => { const fake = new DrillFakeDatabase(); - const ids = ["drill-1", "drill-2", "page-1", "page-2", "page-3"]; + const ids = ["drill-1", "drill-2", "set-1", "set-2", "set-3"]; const times = [20, 10]; const repository = new SqliteDrillRepository(fake.database, { idFactory: () => ids.shift()!, @@ -15,6 +15,7 @@ describe("SqliteDrillRepository", () => { const second = await repository.createDrill("Second"); expect(first).toMatchObject({ id: "drill-1", + fieldPreset: "football-nfhs", createdAt: 20, updatedAt: 20, }); @@ -28,148 +29,162 @@ describe("SqliteDrillRepository", () => { "drill-1", ]); - const firstPage = await repository.createPage({ + const firstSet = await repository.createSet({ drillId: first.id, - label: "Start", - position: { xMeters: 1, yMeters: 2 }, + number: 31, + position: { xSteps: 0, ySteps: 0 }, }); - const secondPage = await repository.createPage({ + const secondSet = await repository.createSet({ drillId: first.id, - label: "Second", - countsFromPrevious: 2.5, - position: { xMeters: 3, yMeters: 4 }, + number: 32, + countsFromPrevious: 16, + measureRange: { start: 126, end: 129 }, + position: { xSteps: 0, ySteps: 32 }, }); - expect(firstPage).toMatchObject({ - id: "page-1", + expect(firstSet).toMatchObject({ + id: "set-1", ordinal: 0, + number: 31, + kind: "set", countsFromPrevious: 0, }); - expect(secondPage).toMatchObject({ id: "page-2", ordinal: 1 }); - expect((await repository.listPages(first.id)).map(({ id }) => id)).toEqual([ - "page-1", - "page-2", + expect(secondSet).toMatchObject({ + id: "set-2", + ordinal: 1, + number: 32, + countsFromPrevious: 16, + measureRange: { start: 126, end: 129 }, + position: { xSteps: 0, ySteps: 32 }, + }); + expect((await repository.listSets(first.id)).map(({ id }) => id)).toEqual([ + "set-1", + "set-2", ]); }); - test("inserts, reorders, updates, and deletes pages through transactions", async () => { + test("inserts, reorders, updates, and deletes sets through transactions", async () => { const fake = new DrillFakeDatabase(); - const ids = ["drill", "page-a", "page-b", "page-inserted"]; + const ids = ["drill", "set-a", "set-b", "set-inserted"]; const repository = new SqliteDrillRepository(fake.database, { idFactory: () => ids.shift()!, timeFactory: () => 1, }); const drill = await repository.createDrill({ name: "Practice" }); - await repository.createPage({ + await repository.createSet({ drillId: drill.id, - label: "A", - position: { xMeters: 0, yMeters: 0 }, + number: 1, + position: { xSteps: 0, ySteps: 0 }, }); - await repository.createPage({ + await repository.createSet({ drillId: drill.id, - label: "B", - position: { xMeters: 2, yMeters: 2 }, + number: 2, + countsFromPrevious: 8, + position: { xSteps: 8, ySteps: 8 }, }); - await repository.insertPage(drill.id, 1, { - label: "Inserted", - countsFromPrevious: 1, - position: { xMeters: 1, yMeters: 1 }, + await repository.insertSet(drill.id, 1, { + number: 1, + suffix: "A", + kind: "subset", + countsFromPrevious: 4, + position: { xSteps: 4, ySteps: 4 }, }); expect( - (await repository.listPages(drill.id)).map((page) => [ - page.id, - page.ordinal, - ]), + (await repository.listSets(drill.id)).map((set) => [set.id, set.ordinal]), ).toEqual([ - ["page-a", 0], - ["page-inserted", 1], - ["page-b", 2], + ["set-a", 0], + ["set-inserted", 1], + ["set-b", 2], ]); - await repository.reorderPages(drill.id, [ - "page-b", - "page-a", - "page-inserted", + await repository.reorderSets(drill.id, [ + "set-a", + "set-inserted", + "set-b", ]); - expect((await repository.listPages(drill.id)).map(({ id }) => id)).toEqual([ - "page-b", - "page-a", - "page-inserted", + expect((await repository.listSets(drill.id)).map(({ id }) => id)).toEqual([ + "set-a", + "set-inserted", + "set-b", ]); - await repository.updatePage("page-inserted", { - label: "Updated", - position: { xMeters: 9, yMeters: 10 }, + await repository.updateSet("set-inserted", { + suffix: ".5", + countsFromPrevious: 6, + measureRange: { start: 10, end: 11 }, + position: { xSteps: 6, ySteps: 9 }, + facingDegrees: 90, }); - expect(await repository.getPage("page-inserted")).toMatchObject({ - label: "Updated", - position: { xMeters: 9, yMeters: 10 }, + expect(await repository.getSet("set-inserted")).toMatchObject({ + number: 1, + suffix: ".5", + kind: "subset", + countsFromPrevious: 6, + measureRange: { start: 10, end: 11 }, + position: { xSteps: 6, ySteps: 9 }, + facingDegrees: 90, }); - await repository.deletePage("page-a"); + await repository.deleteSet("set-inserted"); expect( - (await repository.listPages(drill.id)).map((page) => [ - page.id, - page.ordinal, - ]), + (await repository.listSets(drill.id)).map((set) => [set.id, set.ordinal]), ).toEqual([ - ["page-b", 0], - ["page-inserted", 1], + ["set-a", 0], + ["set-b", 1], ]); expect(fake.database.withTransactionAsync).toHaveBeenCalled(); }); test("persists active and selected pointers, validates selection, and honors FK deletion contracts", async () => { const fake = new DrillFakeDatabase(); - const ids = ["drill-1", "drill-2", "page-1"]; + const ids = ["drill-1", "drill-2", "set-1"]; const repository = new SqliteDrillRepository(fake.database, { idFactory: () => ids.shift()!, timeFactory: () => 1, }); const first = await repository.createDrill("First"); const second = await repository.createDrill("Second"); - const page = await repository.createPage({ + const set = await repository.createSet({ drillId: first.id, - label: "Page", - position: { xMeters: 0, yMeters: 0 }, + number: 1, + position: { xSteps: 0, ySteps: 0 }, }); await repository.setActiveDrill(first.id); - await expect( - repository.setSelectedDrillPage(page.id), - ).resolves.toMatchObject({ + await expect(repository.setSelectedDrillSet(set.id)).resolves.toMatchObject({ activeDrillId: first.id, - selectedDrillPageId: page.id, + selectedDrillSetId: set.id, + }); + await expect(repository.setSelectedDrillSet("missing")).rejects.toMatchObject({ + code: "INVALID_SELECTION", }); - await expect( - repository.setSelectedDrillPage("missing"), - ).rejects.toMatchObject({ code: "INVALID_SELECTION" }); await expect(repository.setActiveDrill("missing")).rejects.toMatchObject({ code: "DRILL_NOT_FOUND", }); await expect(repository.setActiveDrill(second.id)).resolves.toMatchObject({ activeDrillId: second.id, - selectedDrillPageId: null, + selectedDrillSetId: null, + }); + await expect(repository.setSelectedDrillSet(set.id)).rejects.toMatchObject({ + code: "INVALID_SELECTION", }); - await expect( - repository.setSelectedDrillPage(page.id), - ).rejects.toMatchObject({ code: "INVALID_SELECTION" }); await repository.setActiveDrill(first.id); - await repository.setSelectedDrillPage(page.id); - await repository.deletePage(page.id); + await repository.setSelectedDrillSet(set.id); + await repository.deleteSet(set.id); expect(fake.settings.selected_drill_page_id).toBeNull(); await repository.setActiveDrill(first.id); await repository.deleteDrill(first.id); expect(fake.settings.active_drill_id).toBeNull(); - expect(fake.pages.size).toBe(0); + expect(fake.sets.size).toBe(0); }); - test("rejects malformed names, labels, counts, and coordinates", async () => { + test("rejects malformed set data and enforces subset/primary structure", async () => { const fake = new DrillFakeDatabase(); + const ids = ["drill", "set-1", "set-2"]; const repository = new SqliteDrillRepository(fake.database, { - idFactory: () => "drill", + idFactory: () => ids.shift()!, timeFactory: () => 1, }); const drill = await repository.createDrill("Drill"); @@ -178,46 +193,80 @@ describe("SqliteDrillRepository", () => { code: "INVALID_INPUT", }); await expect( - repository.createPage({ + repository.createSet({ + drillId: drill.id, + number: 1, + countsFromPrevious: 1, + position: { xSteps: 0, ySteps: 0 }, + }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + await expect( + repository.createSet({ + drillId: drill.id, + number: 1, + countsFromPrevious: 0, + position: { xSteps: Number.NaN, ySteps: 0 }, + }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + + await repository.createSet({ + drillId: drill.id, + number: 1, + position: { xSteps: 0, ySteps: 0 }, + }); + await expect( + repository.createSet({ drillId: drill.id, - label: "Page", - countsFromPrevious: -1, - position: { xMeters: 0, yMeters: 0 }, + number: 99, + kind: "subset", + suffix: "A", + countsFromPrevious: 8, + position: { xSteps: 8, ySteps: 0 }, }), ).rejects.toMatchObject({ code: "INVALID_INPUT" }); await expect( - repository.createPage({ + repository.createSet({ drillId: drill.id, - label: "Page", - position: { xMeters: Number.NaN, yMeters: 0 }, + number: 2, + countsFromPrevious: 2.5, + position: { xSteps: 8, ySteps: 0 }, }), - ).rejects.toThrow("xMeters"); + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); }); }); type FakeDrillRow = { id: string; name: string; + field_preset: "football-nfhs"; created_at: number; updated_at: number; }; -type FakePageRow = { +type FakeSetRow = { id: string; drill_id: string; ordinal: number; - label: string; + set_number: number; + set_suffix: string | null; + set_kind: "set" | "subset"; counts_from_previous: number; + measure_start: number | null; + measure_end: number | null; + x_steps: number; + y_steps: number; + facing_degrees: number | null; + label: string; x_meters: number; y_meters: number; }; class DrillFakeDatabase { readonly drills = new Map(); - readonly pages = new Map(); + readonly sets = new Map(); readonly settings = { drill_features_enabled: 1, - drill_terminology: "pages", + drill_terminology: "sets", field_perspective: "director", transition_metric_mode: "step-size", guidance_enabled: 1, @@ -245,16 +294,20 @@ class DrillFakeDatabase { this.run(sql, params), ), withTransactionAsync: jest.fn(async (task: () => Promise) => { - const drills = new Map(this.drills); - const pages = new Map(this.pages); + const drills = new Map( + [...this.drills].map(([id, row]) => [id, { ...row }]), + ); + const sets = new Map( + [...this.sets].map(([id, row]) => [id, { ...row }]), + ); const settings = { ...this.settings }; try { await task(); } catch (error) { this.drills.clear(); - this.pages.clear(); + this.sets.clear(); for (const [id, row] of drills) this.drills.set(id, row); - for (const [id, row] of pages) this.pages.set(id, row); + for (const [id, row] of sets) this.sets.set(id, row); Object.assign(this.settings, settings); throw error; } @@ -266,10 +319,10 @@ class DrillFakeDatabase { } private async getFirst(sql: string, params: unknown[]): Promise { - if (sql.includes("COUNT(*) AS page_count")) { + if (sql.includes("COUNT(*) AS set_count")) { return { - page_count: [...this.pages.values()].filter( - (page) => page.drill_id === params[0], + set_count: [...this.sets.values()].filter( + (set) => set.drill_id === params[0], ).length, }; } @@ -277,8 +330,18 @@ class DrillFakeDatabase { const row = this.drills.get(String(params[0])); return row ? { ...row } : null; } - if (sql.includes("FROM drill_pages") && sql.includes("drill_id")) { - const row = this.pages.get(String(params[0])); + if (sql.includes("SELECT id FROM drill_pages") && sql.includes("LIMIT 1")) { + const row = [...this.sets.values()] + .filter((set) => set.drill_id === params[0]) + .sort((left, right) => left.ordinal - right.ordinal)[0]; + return row ? { id: row.id } : null; + } + if (sql.includes("SELECT drill_id FROM drill_pages")) { + const row = this.sets.get(String(params[0])); + return row ? { drill_id: row.drill_id } : null; + } + if (sql.includes("FROM drill_pages")) { + const row = this.sets.get(String(params[0])); return row ? { ...row } : null; } if (sql.includes("FROM app_settings")) return { ...this.settings }; @@ -290,69 +353,111 @@ class DrillFakeDatabase { return [...this.drills.values()] .sort( (left, right) => - left.created_at - right.created_at || - left.id.localeCompare(right.id), + left.created_at - right.created_at || left.id.localeCompare(right.id), ) .map((row) => ({ ...row })); } if (sql.includes("FROM drill_pages")) { - return [...this.pages.values()] - .filter((page) => page.drill_id === params[0]) + const rows = [...this.sets.values()] + .filter((set) => set.drill_id === params[0]) .sort( (left, right) => left.ordinal - right.ordinal || left.id.localeCompare(right.id), - ) - .map((row) => - sql.includes("SELECT id\n") ? { id: row.id } : { ...row }, ); + return rows.map((row) => + /^\s*SELECT id\s+FROM drill_pages/m.test(sql) ? { id: row.id } : { ...row }, + ); } return []; } private async run(sql: string, params: unknown[]): Promise { if (sql.includes("INSERT INTO drills")) { - const [id, name, createdAt, updatedAt] = params as [ + const [id, name, fieldPreset, createdAt, updatedAt] = params as [ string, string, + "football-nfhs", number, number, ]; this.drills.set(id, { id, name, + field_preset: fieldPreset, created_at: createdAt, updated_at: updatedAt, }); } else if (sql.includes("INSERT INTO drill_pages")) { - const [id, drillId, ordinal, label, counts, xMeters, yMeters] = - params as [string, string, number, string, number, number, number]; - this.pages.set(id, { + const [ id, - drill_id: drillId, + drillId, ordinal, + number, + suffix, + kind, + counts, + measureStart, + measureEnd, + xSteps, + ySteps, + facingDegrees, label, + xMeters, + yMeters, + ] = params as [ + string, + string, + number, + number, + string | null, + "set" | "subset", + number, + number | null, + number | null, + number, + number, + number | null, + string, + number, + number, + ]; + this.sets.set(id, { + id, + drill_id: drillId, + ordinal, + set_number: number, + set_suffix: suffix, + set_kind: kind, counts_from_previous: counts, + measure_start: measureStart, + measure_end: measureEnd, + x_steps: xSteps, + y_steps: ySteps, + facing_degrees: facingDegrees, + label, x_meters: xMeters, y_meters: yMeters, }); } else if (sql.includes("INSERT OR IGNORE INTO app_settings")) { - // The singleton already exists in this fake. + // Singleton already exists in this fake. } else if (sql.includes("DELETE FROM drills")) { const id = String(params[0]); this.drills.delete(id); - for (const [pageId, page] of this.pages) { - if (page.drill_id === id) { - this.pages.delete(pageId); - if (this.settings.selected_drill_page_id === pageId) { + for (const [setId, set] of this.sets) { + if (set.drill_id === id) { + this.sets.delete(setId); + if (this.settings.selected_drill_page_id === setId) { this.settings.selected_drill_page_id = null; } } } - if (this.settings.active_drill_id === id) + if (this.settings.active_drill_id === id) { this.settings.active_drill_id = null; + this.settings.selected_drill_page_id = null; + } } else if (sql.includes("DELETE FROM drill_pages")) { const id = String(params[0]); - this.pages.delete(id); + this.sets.delete(id); if (this.settings.selected_drill_page_id === id) { this.settings.selected_drill_page_id = null; } @@ -363,75 +468,130 @@ class DrillFakeDatabase { } else if (sql.includes("UPDATE app_settings")) { this.updateSettings(sql, params); } else if (sql.includes("UPDATE drill_pages")) { - this.updatePages(sql, params); + this.updateSets(sql, params); } return { lastInsertRowId: 1, changes: 1 }; } private updateSettings(sql: string, params: unknown[]): void { if (sql.includes("active_drill_id = ?")) { - this.settings.active_drill_id = params[0] as string | null; + const next = params[0] as string | null; + if (this.settings.active_drill_id !== next) { + this.settings.selected_drill_page_id = null; + } + this.settings.active_drill_id = next; + return; } if (sql.includes("selected_drill_page_id = NULL")) { this.settings.selected_drill_page_id = null; - } else if (sql.includes("selected_drill_page_id = ?")) { + return; + } + if (sql.includes("selected_drill_page_id = ?")) { this.settings.selected_drill_page_id = params[0] as string; } } - private updatePages(sql: string, params: unknown[]): void { - if (sql.includes("ordinal = ordinal + ?")) { + private updateSets(sql: string, params: unknown[]): void { + if (sql.includes("SET ordinal = ordinal + ?")) { const [offset, drillId] = params as [number, string]; - for (const page of this.pages.values()) { - if (page.drill_id === drillId) page.ordinal += offset; + for (const set of this.sets.values()) { + if (set.drill_id === drillId) set.ordinal += offset; } return; } - if (sql.includes("ordinal = CASE")) { + if (sql.includes("SET ordinal = CASE")) { const [threshold, offset, , drillId] = params as [ number, number, number, string, ]; - for (const page of this.pages.values()) { - if (page.drill_id === drillId) { - page.ordinal = - page.ordinal >= threshold - ? page.ordinal - offset + 1 - : page.ordinal - offset; + for (const set of this.sets.values()) { + if (set.drill_id === drillId) { + set.ordinal = + set.ordinal >= threshold + ? set.ordinal - offset + 1 + : set.ordinal - offset; } } return; } - if (sql.includes("ordinal = ordinal - 1")) { + if (sql.includes("SET ordinal = ordinal - 1")) { const [drillId, ordinal] = params as [string, number]; - for (const page of this.pages.values()) { - if (page.drill_id === drillId && page.ordinal > ordinal) - page.ordinal -= 1; + for (const set of this.sets.values()) { + if (set.drill_id === drillId && set.ordinal > ordinal) set.ordinal -= 1; } return; } if (sql.includes("SET ordinal = ?")) { const [ordinal, id] = params as [number, string, string]; - const page = this.pages.get(id); - if (page) page.ordinal = ordinal; + const set = this.sets.get(id); + if (set) set.ordinal = ordinal; return; } - const id = String(params[params.length - 1]); - const page = this.pages.get(id); - if (!page) return; - if (sql.includes("label = ?")) page.label = String(params[0]); - if (sql.includes("counts_from_previous = ?")) { - page.counts_from_previous = Number( - sql.includes("label = ?") ? params[1] : params[0], + if ( + sql.includes("SET counts_from_previous = 0") && + sql.includes("ordinal = 0") + ) { + const drillId = String(params[0]); + const first = [...this.sets.values()].find( + (set) => set.drill_id === drillId && set.ordinal === 0, ); + if (first) first.counts_from_previous = 0; + return; } - if (sql.includes("x_meters = ?")) { - const offset = sql.includes("label = ?") ? 1 : 0; - const countOffset = sql.includes("counts_from_previous = ?") ? 1 : 0; - page.x_meters = Number(params[offset + countOffset]); - page.y_meters = Number(params[offset + countOffset + 1]); + if (sql.includes("SET counts_from_previous = 0 WHERE id = ?")) { + const set = this.sets.get(String(params[0])); + if (set) set.counts_from_previous = 0; + return; + } + if (sql.includes("SET set_number = ?")) { + const [ + number, + suffix, + kind, + counts, + measureStart, + measureEnd, + xSteps, + ySteps, + facingDegrees, + label, + xMeters, + yMeters, + id, + ] = params as [ + number, + string | null, + "set" | "subset", + number, + number | null, + number | null, + number, + number, + number | null, + string, + number, + number, + string, + ]; + const set = this.sets.get(id); + if (set) { + Object.assign(set, { + set_number: number, + set_suffix: suffix, + set_kind: kind, + counts_from_previous: counts, + measure_start: measureStart, + measure_end: measureEnd, + x_steps: xSteps, + y_steps: ySteps, + facing_degrees: facingDegrees, + label, + x_meters: xMeters, + y_meters: yMeters, + }); + } } } } diff --git a/packages/mobile/src/drill/analysis.ts b/packages/mobile/src/drill/analysis.ts index 9b9daf87..e249c476 100644 --- a/packages/mobile/src/drill/analysis.ts +++ b/packages/mobile/src/drill/analysis.ts @@ -1,13 +1,13 @@ -import { assertFiniteFieldPoint, type FieldPoint } from "../field/types"; -import { - metersToStandardSteps, - STANDARD_STEPS_PER_FIVE_YARDS, -} from "../field/units"; -import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "../field/template"; -import type { DrillPage } from "./types"; - -const POSITION_EPSILON_METERS = 1e-9; +import type { DrillGridPoint } from "@eight2five/drill-schema"; + +import type { DrillSet } from "./types"; + +const POSITION_EPSILON_STEPS = 1e-9; const NUMBER_EPSILON = 1e-8; +const STANDARD_STEPS_PER_FIVE_YARDS = 8; +const FIVE_YARD_GRID_LINES = Object.freeze( + Array.from({ length: 21 }, (_, index) => -80 + index * 8), +); export interface TransitionAnalysis { readonly distanceSteps: number; @@ -17,10 +17,14 @@ export interface TransitionAnalysis { } function assertCounts(counts: number): void { - if (!Number.isFinite(counts) || counts < 0) { - throw new RangeError( - "Transition counts must be a finite non-negative number.", - ); + if (!Number.isInteger(counts) || counts < 0) { + throw new RangeError("Transition counts must be a non-negative integer."); + } +} + +function assertGridPoint(point: DrillGridPoint, name: string): void { + if (!Number.isFinite(point.xSteps) || !Number.isFinite(point.ySteps)) { + throw new RangeError(`${name} must contain finite xSteps and ySteps.`); } } @@ -34,42 +38,43 @@ function roundToQuarter(value: number): number { return Number((Math.round(value * 4) / 4).toFixed(2)); } -function isSamePoint(start: FieldPoint, end: FieldPoint): boolean { +function isSamePoint(start: DrillGridPoint, end: DrillGridPoint): boolean { return ( - Math.abs(start.xMeters - end.xMeters) <= POSITION_EPSILON_METERS && - Math.abs(start.yMeters - end.yMeters) <= POSITION_EPSILON_METERS + Math.abs(start.xSteps - end.xSteps) <= POSITION_EPSILON_STEPS && + Math.abs(start.ySteps - end.ySteps) <= POSITION_EPSILON_STEPS ); } function crossingCounts( - start: FieldPoint, - end: FieldPoint, + start: DrillGridPoint, + end: DrillGridPoint, counts: number, ): readonly number[] { - const xDelta = end.xMeters - start.xMeters; - if (Math.abs(xDelta) <= POSITION_EPSILON_METERS || counts === 0) return []; - - const crossings = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE.allFiveYardLines - .map((line) => (line.coordinateMeters - start.xMeters) / xDelta) - .filter( - (progress) => progress > NUMBER_EPSILON && progress < 1 - NUMBER_EPSILON, - ) - .sort((left, right) => left - right) - .map((progress) => cleanNearHalf(progress * counts)); - - return Object.freeze(crossings); + const xDelta = end.xSteps - start.xSteps; + if (Math.abs(xDelta) <= POSITION_EPSILON_STEPS || counts === 0) return []; + + return Object.freeze( + FIVE_YARD_GRID_LINES.map((xSteps) => (xSteps - start.xSteps) / xDelta) + .filter( + (progress) => + progress > NUMBER_EPSILON && progress < 1 - NUMBER_EPSILON, + ) + .sort((left, right) => left - right) + .map((progress) => cleanNearHalf(progress * counts)), + ); } /** - * Derives all transition metrics from canonical points and counts. These - * values are intentionally never fields on DrillPage or persisted records. + * Derives transition metrics from drill-grid positions and incoming counts. + * Counts remain performer-facing metadata in v1; these convenience metrics do + * not create a musical timeline or persisted step-size field. */ export function analyzeTransition( - previousPosition: FieldPoint | null | undefined, - currentPosition: FieldPoint, + previousPosition: DrillGridPoint | null | undefined, + currentPosition: DrillGridPoint, countsFromPrevious: number, ): TransitionAnalysis { - assertFiniteFieldPoint(currentPosition, "Current position"); + assertGridPoint(currentPosition, "Current position"); assertCounts(countsFromPrevious); if (!previousPosition) { @@ -80,17 +85,15 @@ export function analyzeTransition( }); } - assertFiniteFieldPoint(previousPosition, "Previous position"); - const distanceSteps = metersToStandardSteps( - Math.hypot( - currentPosition.xMeters - previousPosition.xMeters, - currentPosition.yMeters - previousPosition.yMeters, - ), + assertGridPoint(previousPosition, "Previous position"); + const distanceSteps = Math.hypot( + currentPosition.xSteps - previousPosition.xSteps, + currentPosition.ySteps - previousPosition.ySteps, ); const isHalt = countsFromPrevious > 0 && isSamePoint(previousPosition, currentPosition); const stepSizeToFive = - countsFromPrevious > 0 && distanceSteps > POSITION_EPSILON_METERS + countsFromPrevious > 0 && distanceSteps > POSITION_EPSILON_STEPS ? roundToQuarter( (countsFromPrevious * STANDARD_STEPS_PER_FIVE_YARDS) / distanceSteps, ) @@ -109,13 +112,13 @@ export function analyzeTransition( } export function analyzeDrillTransition( - previousPage: DrillPage | null | undefined, - currentPage: DrillPage, + previousSet: DrillSet | null | undefined, + currentSet: DrillSet, ): TransitionAnalysis { return analyzeTransition( - previousPage?.position, - currentPage.position, - currentPage.countsFromPrevious, + previousSet?.position, + currentSet.position, + currentSet.countsFromPrevious, ); } diff --git a/packages/mobile/src/drill/index.ts b/packages/mobile/src/drill/index.ts index 93ce2149..71100a1d 100644 --- a/packages/mobile/src/drill/index.ts +++ b/packages/mobile/src/drill/index.ts @@ -1,3 +1,9 @@ +export { + formatSetName, + type DrillGridPoint, + type MeasureRange, + type SetKind, +} from "@eight2five/drill-schema"; export * from "./types"; export * from "./terminology"; export * from "./analysis"; diff --git a/packages/mobile/src/drill/types.ts b/packages/mobile/src/drill/types.ts index 15f4847b..89a3ac28 100644 --- a/packages/mobile/src/drill/types.ts +++ b/packages/mobile/src/drill/types.ts @@ -1,23 +1,41 @@ -import type { FieldPoint } from "../field"; +import type { + DrillGridPoint, + MeasureRange, + SetKind, +} from "@eight2five/drill-schema"; /** - * A drill is deliberately performer-agnostic in this phase. Performer - * identity, assignment, and per-performer positions belong to a later domain - * layer and must not leak into these shared page records. + * App-local drill metadata. The portable drill document owns richer metadata; + * SQLite keeps only the fields needed by the current mobile MVP. */ export interface Drill { readonly id: string; readonly name: string; readonly createdAt: number; readonly updatedAt: number; + readonly fieldPreset: "football-nfhs"; } -/** One performer-independent target page in a drill. */ -export interface DrillPage { +/** + * One ordered target set for the single-performer mobile MVP. + * + * `id` is an opaque SQLite row identifier. It intentionally differs from the + * portable schema's zero-based set id: imports map portable set order to local + * rows, while mobile editing can insert/reorder rows without exposing storage + * identity in drill files. + */ +export interface DrillSet { readonly id: string; readonly drillId: string; readonly ordinal: number; - readonly label: string; + readonly number: number; + readonly suffix?: string; + readonly kind: SetKind; readonly countsFromPrevious: number; - readonly position: FieldPoint; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly facingDegrees?: number; } + +/** @deprecated Use DrillSet. Kept as a source-compatibility alias during v1 migration. */ +export type DrillPage = DrillSet; diff --git a/packages/mobile/src/settings/SqliteSettingsRepository.ts b/packages/mobile/src/settings/SqliteSettingsRepository.ts index d7c74b0c..87cc86ab 100644 --- a/packages/mobile/src/settings/SqliteSettingsRepository.ts +++ b/packages/mobile/src/settings/SqliteSettingsRepository.ts @@ -10,14 +10,7 @@ import { DEFAULT_APP_SETTINGS, normalizeAppSettings } from "./types"; type SqlValue = string | number | null; type AppSettingsRow = Record; -/** - * SQLite implementation for the singleton app settings row. - * - * Parameterized `runAsync` calls are intentionally used directly. Expo SQLite - * documents `runAsync` as a prepare/execute/finalize convenience wrapper, so - * an explicit prepared-statement loop would add complexity without changing - * the safety or performance contract needed by these small writes. - */ +/** SQLite implementation for the singleton app settings row. */ export class SqliteSettingsRepository implements AppSettingsRepository { constructor(private readonly db: SQLiteDatabase) {} @@ -45,13 +38,12 @@ export class SqliteSettingsRepository implements AppSettingsRepository { async resetPreferences(): Promise { await this.load(); - // Deliberately omit both selection columns: resetPreferences must not - // overwrite activeDrillId or selectedDrillPageId, even if another caller - // changes a selection between the initial load and this write. + // Deliberately omit both selection columns. The legacy physical + // drill_terminology column is pinned to "sets" and is no longer exposed. await this.db.runAsync( `UPDATE ${APP_SETTINGS_TABLE} SET drill_features_enabled = ?, - drill_terminology = ?, + drill_terminology = 'sets', field_perspective = ?, transition_metric_mode = ?, guidance_enabled = ?, @@ -62,7 +54,6 @@ export class SqliteSettingsRepository implements AppSettingsRepository { WHERE singleton_id = ?`, [ boolToSql(DEFAULT_APP_SETTINGS.drillFeaturesEnabled), - DEFAULT_APP_SETTINGS.drillTerminology, DEFAULT_APP_SETTINGS.fieldPerspective, DEFAULT_APP_SETTINGS.transitionMetricMode, boolToSql(DEFAULT_APP_SETTINGS.guidanceEnabled), @@ -112,10 +103,10 @@ export class SqliteSettingsRepository implements AppSettingsRepository { comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, 'sets', ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton_id) DO UPDATE SET drill_features_enabled = excluded.drill_features_enabled, - drill_terminology = excluded.drill_terminology, + drill_terminology = 'sets', field_perspective = excluded.field_perspective, transition_metric_mode = excluded.transition_metric_mode, guidance_enabled = excluded.guidance_enabled, @@ -128,7 +119,6 @@ export class SqliteSettingsRepository implements AppSettingsRepository { [ 1, boolToSql(normalized.drillFeaturesEnabled), - normalized.drillTerminology, normalized.fieldPerspective, normalized.transitionMetricMode, boolToSql(normalized.guidanceEnabled), @@ -137,7 +127,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { boolToSql(normalized.showComfortableAnchorRange), normalized.comfortableAnchorRangeMeters, normalized.activeDrillId, - normalized.selectedDrillPageId, + normalized.selectedDrillSetId, ], ); } @@ -151,7 +141,6 @@ export function normalizeAppSettingsRow(row: unknown): AppSettings { function fromRow(row: AppSettingsRow): AppSettings { return normalizeAppSettings({ drillFeaturesEnabled: sqliteBoolean(row.drill_features_enabled), - drillTerminology: row.drill_terminology, fieldPerspective: row.field_perspective, transitionMetricMode: row.transition_metric_mode, guidanceEnabled: sqliteBoolean(row.guidance_enabled), @@ -162,14 +151,14 @@ function fromRow(row: AppSettingsRow): AppSettings { ), comfortableAnchorRangeMeters: row.comfortable_anchor_range_meters, activeDrillId: row.active_drill_id, - selectedDrillPageId: row.selected_drill_page_id, + selectedDrillSetId: row.selected_drill_page_id, }); } function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { return ( row.drill_features_enabled === boolToSql(settings.drillFeaturesEnabled) && - row.drill_terminology === settings.drillTerminology && + row.drill_terminology === "sets" && row.field_perspective === settings.fieldPerspective && row.transition_metric_mode === settings.transitionMetricMode && row.guidance_enabled === boolToSql(settings.guidanceEnabled) && @@ -181,7 +170,7 @@ function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { row.comfortable_anchor_range_meters === settings.comfortableAnchorRangeMeters && row.active_drill_id === settings.activeDrillId && - row.selected_drill_page_id === settings.selectedDrillPageId + row.selected_drill_page_id === settings.selectedDrillSetId ); } diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts index d5fd452c..ab98b6ef 100644 --- a/packages/mobile/src/settings/__tests__/repository.test.ts +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -15,7 +15,7 @@ describe("app settings", () => { await expect(repository.load()).resolves.toEqual(DEFAULT_APP_SETTINGS); expect(fake.row).toMatchObject({ drill_features_enabled: 1, - drill_terminology: "pages", + drill_terminology: "sets", field_perspective: "director", transition_metric_mode: "step-size", guidance_enabled: 1, @@ -26,10 +26,10 @@ describe("app settings", () => { }); }); - test("normalizes every invalid persisted value and keeps stale overlay flags", async () => { + test("normalizes invalid persisted values and pins legacy terminology to sets", async () => { const fake = new SettingsFakeDatabase({ drill_features_enabled: 2, - drill_terminology: "unknown", + drill_terminology: "pages", field_perspective: "unknown", transition_metric_mode: "unknown", guidance_enabled: "yes", @@ -49,6 +49,7 @@ describe("app settings", () => { showCachedAnchorGeometry: true, showComfortableAnchorRange: true, }); + expect(fake.row?.drill_terminology).toBe("sets"); expect(fake.database.runAsync).toHaveBeenCalled(); expect(getEffectiveAppSettings(loaded)).toMatchObject({ developerModeEnabled: false, @@ -57,7 +58,7 @@ describe("app settings", () => { }); }); - test("updates only supplied fields and normalizes invalid updates", async () => { + test("updates supplied fields while preserving drill/set selection", async () => { const fake = new SettingsFakeDatabase({ drill_features_enabled: 1, drill_terminology: "pages", @@ -69,31 +70,31 @@ describe("app settings", () => { show_comfortable_anchor_range: 1, comfortable_anchor_range_meters: 30, active_drill_id: "drill-1", - selected_drill_page_id: "page-1", + selected_drill_page_id: "set-1", }); const repository = new SqliteSettingsRepository(fake.database); const updated = await repository.update({ - drillTerminology: "not-a-value" as never, comfortableAnchorRangeMeters: -1, }); expect(updated).toMatchObject({ drillFeaturesEnabled: true, - drillTerminology: "pages", + drillTerminology: "sets", comfortableAnchorRangeMeters: 20, activeDrillId: "drill-1", - selectedDrillPageId: "page-1", + selectedDrillSetId: "set-1", + selectedDrillPageId: "set-1", }); expect(updated.developerModeEnabled).toBe(true); expect(updated.showCachedAnchorGeometry).toBe(true); expect(updated.showComfortableAnchorRange).toBe(true); }); - test("resetPreferences restores nine preference fields but preserves selection", async () => { + test("resetPreferences restores preference fields but preserves selection", async () => { const fake = new SettingsFakeDatabase({ drill_features_enabled: 0, - drill_terminology: "sets", + drill_terminology: "pages", field_perspective: "performer", transition_metric_mode: "crossing-counts", guidance_enabled: 0, @@ -102,7 +103,7 @@ describe("app settings", () => { show_comfortable_anchor_range: 1, comfortable_anchor_range_meters: 7, active_drill_id: "drill-1", - selected_drill_page_id: "page-2", + selected_drill_page_id: "set-2", }); const repository = new SqliteSettingsRepository(fake.database); @@ -111,8 +112,10 @@ describe("app settings", () => { expect(reset).toEqual({ ...DEFAULT_APP_SETTINGS, activeDrillId: "drill-1", - selectedDrillPageId: "page-2", + selectedDrillSetId: "set-2", + selectedDrillPageId: "set-2", }); + expect(fake.row?.drill_terminology).toBe("sets"); }); test("the pure normalizer treats malformed input as defaults", () => { @@ -126,12 +129,12 @@ describe("app settings", () => { ).toEqual(DEFAULT_APP_SETTINGS); }); - test("clears an impossible selected page when no drill is active", () => { + test("clears an impossible selected set when no drill is active", () => { expect( normalizeAppSettings({ activeDrillId: null, - selectedDrillPageId: "page-1", - }).selectedDrillPageId, + selectedDrillSetId: "set-1", + }).selectedDrillSetId, ).toBeNull(); }); @@ -163,7 +166,19 @@ class SettingsFakeDatabase { this.row = { ...(this.row ?? {}), drill_features_enabled: params[0], - drill_terminology: params[1], + drill_terminology: "sets", + field_perspective: params[1], + transition_metric_mode: params[2], + guidance_enabled: params[3], + developer_mode_enabled: params[4], + show_cached_anchor_geometry: params[5], + show_comfortable_anchor_range: params[6], + comfortable_anchor_range_meters: params[7], + }; + } else { + this.row = { + drill_features_enabled: params[1], + drill_terminology: "sets", field_perspective: params[2], transition_metric_mode: params[3], guidance_enabled: params[4], @@ -171,20 +186,8 @@ class SettingsFakeDatabase { show_cached_anchor_geometry: params[6], show_comfortable_anchor_range: params[7], comfortable_anchor_range_meters: params[8], - }; - } else { - this.row = { - drill_features_enabled: params[1], - drill_terminology: params[2], - field_perspective: params[3], - transition_metric_mode: params[4], - guidance_enabled: params[5], - developer_mode_enabled: params[6], - show_cached_anchor_geometry: params[7], - show_comfortable_anchor_range: params[8], - comfortable_anchor_range_meters: params[9], - active_drill_id: params[10], - selected_drill_page_id: params[11], + active_drill_id: params[9], + selected_drill_page_id: params[10], }; } return { lastInsertRowId: 1, changes: 1 }; diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts index 6e31e5bf..ef949e43 100644 --- a/packages/mobile/src/settings/types.ts +++ b/packages/mobile/src/settings/types.ts @@ -1,21 +1,14 @@ -import type { DrillTerminology } from "../drill/terminology"; - export type FieldPerspective = "director" | "performer"; export type TransitionMetricMode = "step-size" | "crossing-counts"; export const DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS = 20; export const MAX_COMFORTABLE_ANCHOR_RANGE_METERS = 200; -/** - * App preferences and the two persisted selection pointers. - * - * The selection pointers live in the same singleton row as preferences so a - * drill screen can restore its place without introducing another storage - * mechanism. They are intentionally not part of resetPreferences(). - */ +/** App preferences plus persisted drill/set selection pointers. */ export interface AppSettings { readonly drillFeaturesEnabled: boolean; - readonly drillTerminology: DrillTerminology; + /** @deprecated Drill terminology is fixed to Sets in v2. */ + readonly drillTerminology: "sets"; readonly fieldPerspective: FieldPerspective; readonly transitionMetricMode: TransitionMetricMode; readonly guidanceEnabled: boolean; @@ -24,12 +17,14 @@ export interface AppSettings { readonly showComfortableAnchorRange: boolean; readonly comfortableAnchorRangeMeters: number; readonly activeDrillId: string | null; + readonly selectedDrillSetId: string | null; + /** @deprecated Use selectedDrillSetId. */ readonly selectedDrillPageId: string | null; } export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ drillFeaturesEnabled: true, - drillTerminology: "pages", + drillTerminology: "sets", fieldPerspective: "director", transitionMetricMode: "step-size", guidanceEnabled: true, @@ -38,13 +33,12 @@ export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ showComfortableAnchorRange: false, comfortableAnchorRangeMeters: DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS, activeDrillId: null, + selectedDrillSetId: null, selectedDrillPageId: null, }); -/** The preferences reset by resetPreferences, in their public contract order. */ export const APP_PREFERENCE_KEYS = Object.freeze([ "drillFeaturesEnabled", - "drillTerminology", "fieldPerspective", "transitionMetricMode", "guidanceEnabled", @@ -63,10 +57,7 @@ export interface AppSettingsRepository { resetPreferences(): Promise; } -/** - * Normalize values at every storage boundary. Invalid values fall back to the - * field default rather than leaking malformed persisted data to a caller. - */ +/** Normalize untrusted persisted settings at the storage boundary. */ export function normalizeAppSettings(value?: unknown): AppSettings { const candidate = isRecord(value) ? value : {}; const activeDrillId = nullableIdOrNull(candidate.activeDrillId); @@ -75,11 +66,7 @@ export function normalizeAppSettings(value?: unknown): AppSettings { candidate.drillFeaturesEnabled, DEFAULT_APP_SETTINGS.drillFeaturesEnabled, ), - drillTerminology: - candidate.drillTerminology === "pages" || - candidate.drillTerminology === "sets" - ? candidate.drillTerminology - : DEFAULT_APP_SETTINGS.drillTerminology, + drillTerminology: "sets", fieldPerspective: candidate.fieldPerspective === "director" || candidate.fieldPerspective === "performer" @@ -111,18 +98,21 @@ export function normalizeAppSettings(value?: unknown): AppSettings { DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, ), activeDrillId, + selectedDrillSetId: + activeDrillId === null + ? null + : nullableIdOrNull( + candidate.selectedDrillSetId ?? candidate.selectedDrillPageId, + ), selectedDrillPageId: activeDrillId === null ? null - : nullableIdOrNull(candidate.selectedDrillPageId), + : nullableIdOrNull( + candidate.selectedDrillSetId ?? candidate.selectedDrillPageId, + ), }; } -/** - * Return settings as they may be used by UI. Developer overlay preferences - * remain persisted separately, but are ineffective while developer mode is - * disabled. - */ export function getEffectiveAppSettings(value: AppSettings): AppSettings { const normalized = normalizeAppSettings(value); if (normalized.developerModeEnabled) return normalized; @@ -133,7 +123,6 @@ export function getEffectiveAppSettings(value: AppSettings): AppSettings { }; } -/** Alias with selector-oriented naming for store consumers. */ export const selectEffectiveSettings = getEffectiveAppSettings; export const getEffectiveSettings = getEffectiveAppSettings; export const selectEffectiveAppSettings = getEffectiveAppSettings; diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index c72b7301..5ef8fa3b 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -3,10 +3,11 @@ import { migrateMobileDatabase, MOBILE_DB_NAME, MOBILE_SCHEMA_VERSION, + parseLegacySetLabel, } from "../mobileDatabase"; describe("mobile app SQLite migration", () => { - test("creates the relational schema, defaults, indexes, WAL, and foreign keys", async () => { + test("creates the v2 relational schema, set model, defaults, WAL, and foreign keys", async () => { const executed: string[] = []; const database = fakeDatabase(0, executed); @@ -14,20 +15,25 @@ describe("mobile app SQLite migration", () => { const sql = executed.join("\n"); expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); + expect(MOBILE_SCHEMA_VERSION).toBe(2); expect(sql).toContain("PRAGMA journal_mode = WAL"); expect(sql).toContain("PRAGMA foreign_keys = ON"); - expect(sql).toContain( - "CREATE TABLE IF NOT EXISTS mobile_schema_migrations", - ); + expect(sql).toContain("CREATE TABLE IF NOT EXISTS mobile_schema_migrations"); expect(sql).toContain("CREATE TABLE IF NOT EXISTS drills"); + expect(sql).toContain("field_preset TEXT NOT NULL DEFAULT 'football-nfhs'"); expect(sql).toContain("CREATE TABLE IF NOT EXISTS drill_pages"); + expect(sql).toContain("set_number INTEGER NOT NULL"); + expect(sql).toContain("set_kind TEXT NOT NULL"); + expect(sql).toContain("measure_start INTEGER"); + expect(sql).toContain("x_steps REAL NOT NULL"); + expect(sql).toContain("facing_degrees REAL"); expect(sql).toContain("CREATE TABLE IF NOT EXISTS app_settings"); expect(sql).toContain("REFERENCES drills(id) ON DELETE CASCADE"); expect(sql).toContain("REFERENCES drills(id) ON DELETE SET NULL"); expect(sql).toContain("REFERENCES drill_pages(id) ON DELETE SET NULL"); expect(sql).toContain("UNIQUE (drill_id, ordinal)"); - expect(sql).toContain("idx_drill_pages_drill"); - expect(sql).toContain("DEFAULT 'pages'"); + expect(sql).toContain("idx_drill_sets_drill"); + expect(sql).toContain("DEFAULT 'sets'"); expect(sql).toContain("DEFAULT 'director'"); expect(sql).toContain("DEFAULT 'step-size'"); expect(sql).toContain("DEFAULT 20"); @@ -39,6 +45,99 @@ describe("mobile app SQLite migration", () => { expect(database.withTransactionAsync).toHaveBeenCalledTimes(1); }); + test("migrates v1 labels and physical coordinates into explicit set/grid fields", async () => { + const executed: string[] = []; + const database = fakeDatabase(1, executed, [ + { + id: "legacy-1", + drill_id: "drill", + ordinal: 0, + label: "31", + counts_from_previous: 8, + x_meters: 45.72, + y_meters: 0, + }, + { + id: "legacy-2", + drill_id: "drill", + ordinal: 1, + label: "31A", + counts_from_previous: 8, + x_meters: 45.72, + y_meters: 16.256, + }, + { + id: "legacy-3", + drill_id: "drill", + ordinal: 2, + label: "Finale", + counts_from_previous: 2.5, + x_meters: 45.72, + y_meters: 32.512, + }, + ]); + + await migrateMobileDatabase(database); + + const sql = executed.join("\n"); + expect(sql).toContain("ADD COLUMN set_number INTEGER"); + expect(sql).toContain("ADD COLUMN x_steps REAL"); + expect(database.runAsync).toHaveBeenCalledWith( + expect.stringContaining("drill_terminology = 'sets'"), + ); + expect(sql).toContain("PRAGMA user_version = 2"); + + const migrationUpdates = database.runAsync.mock.calls.filter(([statement]) => + String(statement).includes("SET set_number = ?"), + ); + expect(migrationUpdates).toHaveLength(3); + expect(migrationUpdates[0][1]).toEqual([ + 31, + null, + "set", + 0, + expect.closeTo(0, 8), + expect.closeTo(0, 8), + "31", + "legacy-1", + ]); + expect(migrationUpdates[1][1]).toEqual([ + 31, + "A", + "subset", + 8, + expect.closeTo(0, 8), + expect.closeTo(28, 8), + "31A", + "legacy-2", + ]); + expect(migrationUpdates[2][1]).toEqual([ + 3, + null, + "set", + 3, + expect.closeTo(0, 8), + expect.closeTo(56, 8), + "3", + "legacy-3", + ]); + }); + + test("parses only safe numeric and supported subset legacy labels", () => { + expect(parseLegacySetLabel("31")).toEqual({ number: 31, kind: "set" }); + expect(parseLegacySetLabel("31A")).toEqual({ + number: 31, + suffix: "A", + kind: "subset", + }); + expect(parseLegacySetLabel("31.5")).toEqual({ + number: 31, + suffix: ".5", + kind: "subset", + }); + expect(parseLegacySetLabel("Finale")).toBeUndefined(); + }); + test("rejects a database newer than the package schema without migrating it", async () => { const executed: string[] = []; const database = fakeDatabase(MOBILE_SCHEMA_VERSION + 1, executed); @@ -47,18 +146,21 @@ describe("mobile app SQLite migration", () => { `Unsupported mobile database version ${MOBILE_SCHEMA_VERSION + 1}`, ); expect(database.withTransactionAsync).not.toHaveBeenCalled(); - expect(executed.join("\n")).not.toContain( - "CREATE TABLE IF NOT EXISTS drills", - ); + expect(executed.join("\n")).not.toContain("CREATE TABLE IF NOT EXISTS drills"); }); }); -function fakeDatabase(version: number, executed: string[]) { +function fakeDatabase( + version: number, + executed: string[], + rows: readonly Record[] = [], +) { return { execAsync: jest.fn(async (sql: string) => { executed.push(sql); }), getFirstAsync: jest.fn(async () => ({ user_version: version })), + getAllAsync: jest.fn(async () => rows.map((row) => ({ ...row }))), runAsync: jest.fn(async () => ({ lastInsertRowId: 1, changes: 1 })), withTransactionAsync: jest.fn( async (task: () => Promise) => await task(), diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index 4f7b4704..0b8e1bd4 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -1,3 +1,8 @@ +import { + formatSetName, + getFieldPreset, + physicalPointToDrillGrid, +} from "@eight2five/drill-schema"; import type { SQLiteDatabase } from "expo-sqlite"; /** The app database is deliberately separate from the PANS manager database. */ @@ -5,17 +10,23 @@ export const MOBILE_DB_NAME = "eight2five-mobile.db"; export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; /** - * The schema owner for the app database. Repositories never run migrations on - * their own; `openMobileRepositories` calls this function once before it - * constructs either repository. + * v2 replaces arbitrary drill-page labels/physical target coordinates with + * explicit set identity and conventional drill-grid coordinates. Legacy + * columns remain in the physical SQLite table so v1 databases can migrate + * without rebuilding foreign-key relationships. */ -export const MOBILE_SCHEMA_VERSION = 1; +export const MOBILE_SCHEMA_VERSION = 2; export const MOBILE_SCHEMA_MIGRATIONS_TABLE = "mobile_schema_migrations"; export const DRILLS_TABLE = "drills"; -export const DRILL_PAGES_TABLE = "drill_pages"; +export const DRILL_SETS_TABLE = "drill_pages"; +/** @deprecated Physical table alias retained for storage compatibility. */ +export const DRILL_PAGES_TABLE = DRILL_SETS_TABLE; export const APP_SETTINGS_TABLE = "app_settings"; +const NFHS_FIELD = getFieldPreset("football-nfhs"); +const HALF_FIELD_METERS = 45.72; + export class MobileStorageError extends Error { readonly cause?: unknown; @@ -27,11 +38,8 @@ export class MobileStorageError extends Error { } /** - * Migrate the app-side database. - * - * This is intentionally the only migration owner for the mobile database. - * The PANS manager database has its own file and its own user_version and is - * not touched here. + * Migrate the app-side database. This is the only migration owner for this + * database; repositories only consume a completed schema. */ export async function migrateMobileDatabase(db: SQLiteDatabase): Promise { await db.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); @@ -39,7 +47,7 @@ export async function migrateMobileDatabase(db: SQLiteDatabase): Promise { const row = await db.getFirstAsync<{ user_version: number | string }>( "PRAGMA user_version", ); - const currentVersion = parseSchemaVersion(row?.user_version); + let currentVersion = parseSchemaVersion(row?.user_version); if (currentVersion > MOBILE_SCHEMA_VERSION) { throw new MobileStorageError( `Unsupported mobile database version ${currentVersion}.`, @@ -47,85 +55,279 @@ export async function migrateMobileDatabase(db: SQLiteDatabase): Promise { } if (currentVersion === 0) { - await db.withTransactionAsync(async () => { - await db.execAsync(` - CREATE TABLE IF NOT EXISTS ${MOBILE_SCHEMA_MIGRATIONS_TABLE} ( - version INTEGER PRIMARY KEY NOT NULL, - applied_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS ${DRILLS_TABLE} ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL CHECK (length(trim(name)) > 0), - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_drills_created_at - ON ${DRILLS_TABLE}(created_at, id); - - CREATE TABLE IF NOT EXISTS ${DRILL_PAGES_TABLE} ( - id TEXT PRIMARY KEY NOT NULL, - drill_id TEXT NOT NULL - REFERENCES ${DRILLS_TABLE}(id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL - CHECK (ordinal >= 0 AND ordinal = CAST(ordinal AS INTEGER)), - label TEXT NOT NULL CHECK (length(trim(label)) > 0), - counts_from_previous INTEGER NOT NULL - CHECK (counts_from_previous >= 0), - x_meters REAL NOT NULL - CHECK (x_meters = x_meters), - y_meters REAL NOT NULL - CHECK (y_meters = y_meters), - UNIQUE (drill_id, ordinal) - ); - - CREATE INDEX IF NOT EXISTS idx_drill_pages_drill - ON ${DRILL_PAGES_TABLE}(drill_id, ordinal, id); - - CREATE TABLE IF NOT EXISTS ${APP_SETTINGS_TABLE} ( - singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), - drill_features_enabled INTEGER NOT NULL DEFAULT 1 - CHECK (drill_features_enabled IN (0, 1)), - drill_terminology TEXT NOT NULL DEFAULT 'pages' - CHECK (drill_terminology IN ('pages', 'sets')), - field_perspective TEXT NOT NULL DEFAULT 'director' - CHECK (field_perspective IN ('director', 'performer')), - transition_metric_mode TEXT NOT NULL DEFAULT 'step-size' - CHECK (transition_metric_mode IN ('step-size', 'crossing-counts')), - guidance_enabled INTEGER NOT NULL DEFAULT 1 - CHECK (guidance_enabled IN (0, 1)), - developer_mode_enabled INTEGER NOT NULL DEFAULT 0 - CHECK (developer_mode_enabled IN (0, 1)), - show_cached_anchor_geometry INTEGER NOT NULL DEFAULT 0 - CHECK (show_cached_anchor_geometry IN (0, 1)), - show_comfortable_anchor_range INTEGER NOT NULL DEFAULT 0 - CHECK (show_comfortable_anchor_range IN (0, 1)), - comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 - CHECK (comfortable_anchor_range_meters > 0), - active_drill_id TEXT - REFERENCES ${DRILLS_TABLE}(id) ON DELETE SET NULL, - selected_drill_page_id TEXT - REFERENCES ${DRILL_PAGES_TABLE}(id) ON DELETE SET NULL - ); - - INSERT OR IGNORE INTO ${APP_SETTINGS_TABLE} (singleton_id) - VALUES (1); - `); - - await db.runAsync( - `INSERT OR REPLACE INTO ${MOBILE_SCHEMA_MIGRATIONS_TABLE} - (version, applied_at) VALUES (?, ?)`, - [MOBILE_SCHEMA_VERSION, Date.now()], - ); - await db.execAsync(`PRAGMA user_version = ${MOBILE_SCHEMA_VERSION};`); - }); + await createCurrentSchema(db); + currentVersion = MOBILE_SCHEMA_VERSION; + } + + if (currentVersion === 1) { + await migrateVersionOneToTwo(db); + currentVersion = 2; + } + + if (currentVersion !== MOBILE_SCHEMA_VERSION) { + throw new MobileStorageError( + `Mobile database migration stopped at unsupported version ${currentVersion}.`, + ); } // Keep this enabled for every connection, including an already migrated one. await db.execAsync("PRAGMA foreign_keys = ON;"); } +async function createCurrentSchema(db: SQLiteDatabase): Promise { + await db.withTransactionAsync(async () => { + await db.execAsync(` + CREATE TABLE IF NOT EXISTS ${MOBILE_SCHEMA_MIGRATIONS_TABLE} ( + version INTEGER PRIMARY KEY NOT NULL, + applied_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ${DRILLS_TABLE} ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + field_preset TEXT NOT NULL DEFAULT 'football-nfhs' + CHECK (field_preset = 'football-nfhs'), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_drills_created_at + ON ${DRILLS_TABLE}(created_at, id); + + CREATE TABLE IF NOT EXISTS ${DRILL_SETS_TABLE} ( + id TEXT PRIMARY KEY NOT NULL, + drill_id TEXT NOT NULL + REFERENCES ${DRILLS_TABLE}(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL + CHECK (ordinal >= 0 AND ordinal = CAST(ordinal AS INTEGER)), + + set_number INTEGER NOT NULL CHECK (set_number >= 0), + set_suffix TEXT, + set_kind TEXT NOT NULL CHECK (set_kind IN ('set', 'subset')), + counts_from_previous INTEGER NOT NULL + CHECK ( + counts_from_previous >= 0 AND + counts_from_previous = CAST(counts_from_previous AS INTEGER) + ), + measure_start INTEGER, + measure_end INTEGER, + x_steps REAL NOT NULL CHECK (x_steps = x_steps), + y_steps REAL NOT NULL CHECK (y_steps = y_steps), + facing_degrees REAL + CHECK (facing_degrees IS NULL OR (facing_degrees >= 0 AND facing_degrees < 360)), + + -- Legacy compatibility columns. New domain code derives these values. + label TEXT NOT NULL CHECK (length(trim(label)) > 0), + x_meters REAL NOT NULL CHECK (x_meters = x_meters), + y_meters REAL NOT NULL CHECK (y_meters = y_meters), + + CHECK ( + (set_kind = 'set' AND set_suffix IS NULL) OR + (set_kind = 'subset' AND set_suffix IS NOT NULL) + ), + CHECK ( + (measure_start IS NULL AND measure_end IS NULL) OR + (measure_start IS NOT NULL AND measure_end IS NOT NULL AND + measure_start >= 0 AND measure_end >= measure_start) + ), + UNIQUE (drill_id, ordinal), + UNIQUE (drill_id, set_number, set_suffix) + ); + + CREATE INDEX IF NOT EXISTS idx_drill_sets_drill + ON ${DRILL_SETS_TABLE}(drill_id, ordinal, id); + + CREATE TABLE IF NOT EXISTS ${APP_SETTINGS_TABLE} ( + singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), + drill_features_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (drill_features_enabled IN (0, 1)), + drill_terminology TEXT NOT NULL DEFAULT 'sets' + CHECK (drill_terminology IN ('pages', 'sets')), + field_perspective TEXT NOT NULL DEFAULT 'director' + CHECK (field_perspective IN ('director', 'performer')), + transition_metric_mode TEXT NOT NULL DEFAULT 'step-size' + CHECK (transition_metric_mode IN ('step-size', 'crossing-counts')), + guidance_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (guidance_enabled IN (0, 1)), + developer_mode_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (developer_mode_enabled IN (0, 1)), + show_cached_anchor_geometry INTEGER NOT NULL DEFAULT 0 + CHECK (show_cached_anchor_geometry IN (0, 1)), + show_comfortable_anchor_range INTEGER NOT NULL DEFAULT 0 + CHECK (show_comfortable_anchor_range IN (0, 1)), + comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 + CHECK (comfortable_anchor_range_meters > 0), + active_drill_id TEXT + REFERENCES ${DRILLS_TABLE}(id) ON DELETE SET NULL, + selected_drill_page_id TEXT + REFERENCES ${DRILL_SETS_TABLE}(id) ON DELETE SET NULL + ); + + INSERT OR IGNORE INTO ${APP_SETTINGS_TABLE} (singleton_id) + VALUES (1); + `); + await recordMigration(db, MOBILE_SCHEMA_VERSION); + }); +} + +interface LegacySetRow { + readonly id: string; + readonly drill_id: string; + readonly ordinal: number; + readonly label: string; + readonly counts_from_previous: number; + readonly x_meters: number; + readonly y_meters: number; +} + +interface LegacyIdentity { + readonly number: number; + readonly suffix?: string; + readonly kind: "set" | "subset"; +} + +async function migrateVersionOneToTwo(db: SQLiteDatabase): Promise { + await db.withTransactionAsync(async () => { + await db.execAsync(` + ALTER TABLE ${DRILLS_TABLE} + ADD COLUMN field_preset TEXT NOT NULL DEFAULT 'football-nfhs'; + + ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN set_number INTEGER; + ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN set_suffix TEXT; + ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN set_kind TEXT; + ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN measure_start INTEGER; + ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN measure_end INTEGER; + ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN x_steps REAL; + ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN y_steps REAL; + ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN facing_degrees REAL; + `); + + const rows = await db.getAllAsync( + `SELECT id, drill_id, ordinal, label, counts_from_previous, x_meters, y_meters + FROM ${DRILL_SETS_TABLE} + ORDER BY drill_id ASC, ordinal ASC, id ASC`, + ); + const rowsByDrill = new Map(); + for (const legacy of rows) { + const list = rowsByDrill.get(legacy.drill_id) ?? []; + list.push(legacy); + rowsByDrill.set(legacy.drill_id, list); + } + + for (const drillRows of rowsByDrill.values()) { + await migrateLegacyDrillRows(db, drillRows); + } + + await db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} SET drill_terminology = 'sets' WHERE singleton_id = 1`, + ); + await recordMigration(db, 2); + }); +} + +async function migrateLegacyDrillRows( + db: SQLiteDatabase, + rows: readonly LegacySetRow[], +): Promise { + const parsed = rows.map((row) => parseLegacySetLabel(row.label)); + const reservedPrimaryNumbers = new Set(); + for (const identity of parsed) { + if (identity?.kind === "set") reservedPrimaryNumbers.add(identity.number); + } + + const usedPrimaryNumbers = new Set(); + const usedIdentities = new Set(); + + for (const [index, row] of rows.entries()) { + const candidate = parsed[index]; + let identity: LegacyIdentity | undefined; + if (candidate?.kind === "set" && !usedPrimaryNumbers.has(candidate.number)) { + identity = candidate; + } else if ( + candidate?.kind === "subset" && + reservedPrimaryNumbers.has(candidate.number) && + !usedIdentities.has(identityKey(candidate)) + ) { + identity = candidate; + } + + if (!identity) { + let fallback = Math.max(0, row.ordinal + 1); + while ( + reservedPrimaryNumbers.has(fallback) || + usedPrimaryNumbers.has(fallback) + ) { + fallback += 1; + } + identity = { number: fallback, kind: "set" }; + } + + if (identity.kind === "set") usedPrimaryNumbers.add(identity.number); + usedIdentities.add(identityKey(identity)); + + // v1 stored X from the Side-1 goal line. Center it first, then project the + // exact physical position onto the new conventional NFHS marching grid. + const grid = physicalPointToDrillGrid( + { + xMeters: row.x_meters - HALF_FIELD_METERS, + yMeters: row.y_meters, + }, + NFHS_FIELD, + ); + const counts = index === 0 ? 0 : normalizeLegacyCount(row.counts_from_previous); + + await db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} + SET set_number = ?, set_suffix = ?, set_kind = ?, counts_from_previous = ?, + x_steps = ?, y_steps = ?, facing_degrees = NULL, + label = ? + WHERE id = ?`, + [ + identity.number, + identity.suffix ?? null, + identity.kind, + counts, + grid.xSteps, + grid.ySteps, + formatSetName(identity), + row.id, + ], + ); + } +} + +export function parseLegacySetLabel(label: string): LegacyIdentity | undefined { + const match = label.trim().match(/^([0-9]+)([A-Z]|\.[0-9]+)?$/); + if (!match) return undefined; + const number = Number(match[1]); + if (!Number.isSafeInteger(number)) return undefined; + const suffix = match[2]; + return suffix + ? { number, suffix, kind: "subset" } + : { number, kind: "set" }; +} + +function identityKey(identity: LegacyIdentity): string { + return `${identity.number}|${identity.suffix ?? ""}`; +} + +function normalizeLegacyCount(value: number): number { + if (!Number.isFinite(value) || value < 0) return 0; + return Math.round(value); +} + +async function recordMigration( + db: SQLiteDatabase, + version: number, +): Promise { + await db.runAsync( + `INSERT OR REPLACE INTO ${MOBILE_SCHEMA_MIGRATIONS_TABLE} + (version, applied_at) VALUES (?, ?)`, + [version, Date.now()], + ); + await db.execAsync(`PRAGMA user_version = ${version};`); +} + function parseSchemaVersion(value: number | string | undefined): number { if (value === undefined) return 0; const parsed = Number(value); From cd50b654dcb3fcbd67f9143228f5ad71f1b0c056 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 14:20:58 -0500 Subject: [PATCH 028/101] fix(drill-schema): Accept Pyware performer symbols --- packages/drill-schema/drill-document.schema.json | 6 ++++-- packages/drill-schema/src/__tests__/schema.test.ts | 10 ++++++++++ packages/drill-schema/src/schema.ts | 6 +++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/drill-schema/drill-document.schema.json b/packages/drill-schema/drill-document.schema.json index 1938bb07..d8dfdc81 100644 --- a/packages/drill-schema/drill-document.schema.json +++ b/packages/drill-schema/drill-document.schema.json @@ -204,7 +204,8 @@ }, "symbol": { "type": "string", - "pattern": "^[A-Z]$" + "minLength": 1, + "maxLength": 16 }, "label": { "type": "string", @@ -251,7 +252,8 @@ "bySymbol": { "type": "object", "propertyNames": { - "pattern": "^[A-Z]$" + "minLength": 1, + "maxLength": 16 }, "additionalProperties": { "$ref": "#/$defs/entityRuleValues" diff --git a/packages/drill-schema/src/__tests__/schema.test.ts b/packages/drill-schema/src/__tests__/schema.test.ts index f2f25c0f..d1470777 100644 --- a/packages/drill-schema/src/__tests__/schema.test.ts +++ b/packages/drill-schema/src/__tests__/schema.test.ts @@ -117,6 +117,16 @@ describe("drill schema", () => { ).toThrow(/requires a primary set/); }); + it("accepts source symbols beyond A-Z", () => { + expect(() => + parseDrillDocument({ + ...fixture, + entityRules: { bySymbol: { $: { section: "Guard" } } }, + entities: [{ ...fixture.entities[0], symbol: "$", label: "$1" }], + }), + ).not.toThrow(); + }); + it("resolves appearance rules from broad to specific", () => { const entity = resolveDrillEntity(fixture.entities[0], fixture.entityRules); expect(entity.instrument).toBe("Baritone"); diff --git a/packages/drill-schema/src/schema.ts b/packages/drill-schema/src/schema.ts index 6b1b53e7..966e6ceb 100644 --- a/packages/drill-schema/src/schema.ts +++ b/packages/drill-schema/src/schema.ts @@ -15,7 +15,7 @@ const safeNonNegativeInteger = z const finiteNumber = z.number().finite(); const nonEmptyText = z.string().trim().min(1); const hexColor = z.string().regex(/^#[0-9A-Fa-f]{6}$/); -const symbol = z.string().regex(/^[A-Z]$/); +const symbol = z.string().trim().min(1).max(16); const lucideIcon = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); export const measureRangeSchema = z @@ -111,11 +111,11 @@ export const entityRulesSchema = z .strict() .superRefine((rules, context) => { for (const key of Object.keys(rules.bySymbol ?? {})) { - if (!/^[A-Z]$/.test(key)) { + if (key.trim().length === 0 || key.length > 16) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["bySymbol", key], - message: "Symbol rule keys must be one uppercase A-Z letter.", + message: "Symbol rule keys must be 1-16 non-whitespace characters.", }); } } From 685e5b0d765e0348f83b5bf89890c078eb522cfc Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 14:29:30 -0500 Subject: [PATCH 029/101] feat(drill-importers): Parse coordinate sheet PDFs --- package-lock.json | 11 + packages/drill-importers/package.json | 32 + .../src/__tests__/coordinate-sheet.test.ts | 194 +++++ .../drill-importers/src/coordinate-sheet.ts | 750 ++++++++++++++++++ packages/drill-importers/src/index.ts | 2 + packages/drill-importers/src/types.ts | 70 ++ packages/drill-importers/tsconfig.json | 7 + tsconfig.json | 2 + 8 files changed, 1068 insertions(+) create mode 100644 packages/drill-importers/package.json create mode 100644 packages/drill-importers/src/__tests__/coordinate-sheet.test.ts create mode 100644 packages/drill-importers/src/coordinate-sheet.ts create mode 100644 packages/drill-importers/src/index.ts create mode 100644 packages/drill-importers/src/types.ts create mode 100644 packages/drill-importers/tsconfig.json diff --git a/package-lock.json b/package-lock.json index f1da613f..ea36bccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2184,6 +2184,10 @@ "node": ">=0.8.0" } }, + "node_modules/@eight2five/drill-importers": { + "resolved": "packages/drill-importers", + "link": true + }, "node_modules/@eight2five/drill-schema": { "resolved": "packages/drill-schema", "link": true @@ -18980,6 +18984,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/drill-importers": { + "name": "@eight2five/drill-importers", + "version": "0.0.0", + "dependencies": { + "@eight2five/drill-schema": "*" + } + }, "packages/drill-schema": { "name": "@eight2five/drill-schema", "version": "0.0.0", diff --git a/packages/drill-importers/package.json b/packages/drill-importers/package.json new file mode 100644 index 00000000..b3336094 --- /dev/null +++ b/packages/drill-importers/package.json @@ -0,0 +1,32 @@ +{ + "name": "@eight2five/drill-importers", + "version": "0.0.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit -p tsconfig.json", + "test": "jest src --watchAll=false --passWithNoTests --runInBand" + }, + "dependencies": { + "@eight2five/drill-schema": "*" + }, + "jest": { + "testEnvironment": "node", + "transform": { + "^.+\\.[jt]sx?$": [ + "babel-jest", + { + "presets": ["babel-preset-expo"] + } + ] + }, + "moduleNameMapper": { + "^@eight2five/drill-schema$": "/../drill-schema/src/index.ts", + "^@eight2five/drill-schema/(.*)$": "/../drill-schema/src/$1" + } + } +} diff --git a/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts b/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts new file mode 100644 index 00000000..683cf6fe --- /dev/null +++ b/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts @@ -0,0 +1,194 @@ +import { + importCoordinateSheetPages, + parseFrontBack, + parseMeasureRange, + parseSetIdentity, + parseSideToSide, + type ExtractedPdfPage, + type ExtractedPdfTextItem, +} from ".."; + +function item(text: string, x: number, y: number): ExtractedPdfTextItem { + return { text, x, y, width: Math.max(8, text.length * 5), height: 10 }; +} + +function sheetItems({ + offsetX, + performer, + symbol, + label, + id, + sideShift = 0, +}: { + offsetX: number; + performer: string; + symbol: string; + label: string; + id: string; + sideShift?: number; +}): ExtractedPdfTextItem[] { + const x = (value: number) => offsetX + value; + return [ + item( + `Performer: ${performer} Symbol: ${symbol} Label: ${label} ID:${id} Part 4`, + x(0), + 760, + ), + item("Set", x(0), 720), + item("Measure", x(55), 720), + item("Counts", x(115), 720), + item("Side 1-Side 2", x(170), 720), + item("Front-Back", x(300), 720), + item("31", x(0), 700), + item("0", x(55), 700), + item("0", x(115), 700), + item("Side 1: On 45 yd ln", x(170), 700), + item("On Front side line", x(300), 700), + item("32", x(0), 680), + item("126-129", x(55), 680), + item("16", x(115), 680), + item( + `Side 2: ${4 + sideShift}.0 steps Inside 45 yd ln`, + x(170), + 680, + ), + item("4.0 steps Behind Front Hash (HS)", x(300), 680), + ]; +} + +const TWO_UP_PAGE: ExtractedPdfPage = { + pageNumber: 1, + width: 800, + height: 800, + items: [ + ...sheetItems({ + offsetX: 20, + performer: "Ada Lovelace", + symbol: "B", + label: "1", + id: "1595433022185", + }), + ...sheetItems({ + offsetX: 420, + performer: "Grace Hopper", + symbol: "$", + label: "2", + id: "1595433022186", + }), + ], +}; + +describe("coordinate sheet importer", () => { + test("parses two side-by-side Pyware sheets into one portable drill", () => { + const result = importCoordinateSheetPages([TWO_UP_PAGE], { + title: "Part 4", + fileName: "Part 4 Coordinates.pdf", + createdAt: "2026-08-02T18:00:00.000Z", + }); + + expect(result.diagnostics).toEqual([]); + expect(result.sheets).toHaveLength(2); + expect(result.sheets.map((sheet) => sheet.displayLabel)).toEqual(["B1", "$2"]); + expect(result.document).toBeDefined(); + expect(result.document?.sets).toEqual([ + { + id: 0, + number: 31, + kind: "set", + countsFromPrevious: 0, + measureRange: { start: 0, end: 0 }, + }, + { + id: 1, + number: 32, + kind: "set", + countsFromPrevious: 16, + measureRange: { start: 126, end: 129 }, + }, + ]); + expect(result.document?.entities).toMatchObject([ + { + id: 1595433022185, + symbol: "B", + label: "B1", + name: "Ada Lovelace", + }, + { + id: 1595433022186, + symbol: "$", + label: "$2", + name: "Grace Hopper", + }, + ]); + expect(result.document?.positions).toEqual([ + { entityId: 1595433022185, setId: 0, xSteps: -8, ySteps: 0 }, + { entityId: 1595433022185, setId: 1, xSteps: 4, ySteps: 32 }, + { entityId: 1595433022186, setId: 0, xSteps: -8, ySteps: 0 }, + { entityId: 1595433022186, setId: 1, xSteps: 4, ySteps: 32 }, + ]); + }); + + test("rejects global set metadata disagreements instead of silently choosing one", () => { + const mismatched: ExtractedPdfPage = { + ...TWO_UP_PAGE, + items: [ + ...sheetItems({ + offsetX: 20, + performer: "Ada Lovelace", + symbol: "B", + label: "1", + id: "1595433022185", + }), + ...sheetItems({ + offsetX: 420, + performer: "Grace Hopper", + symbol: "$", + label: "2", + id: "1595433022186", + sideShift: 1, + }).map((entry) => + entry.text === "16" && entry.y === 680 + ? { ...entry, text: "12" } + : entry, + ), + ], + }; + + const result = importCoordinateSheetPages([mismatched], { + title: "Part 4", + createdAt: "2026-08-02T18:00:00.000Z", + }); + expect(result.document).toBeUndefined(); + expect(result.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ severity: "error", code: "COUNTS_MISMATCH" }), + ]), + ); + }); + + test("supports Pyware coordinate wording and conventional NFHS hashes", () => { + expect(parseSideToSide("On 50 yd ln")).toBe(0); + expect(parseSideToSide("Side 1: On 45 yd ln")).toBe(-8); + expect(parseSideToSide("Side 1: 2.0 steps Outside 50 yd ln")).toBe(-2); + expect(parseSideToSide("Side 2: 4.0 steps Inside 45 yd ln")).toBe(4); + expect(parseFrontBack("On Front side line")).toBe(0); + expect(parseFrontBack("4.0 steps Behind Front Hash (HS)")).toBe(32); + expect(parseFrontBack("3.5 steps In Front Of Back Hash (HS)")).toBe(52.5); + expect(parseFrontBack("On Back Sideline")).toBe(84); + }); + + test("keeps set identity and measure ranges structured", () => { + expect(parseSetIdentity("31A")).toEqual({ + number: 31, + suffix: "A", + kind: "subset", + }); + expect(parseSetIdentity("31.5")).toEqual({ + number: 31, + suffix: ".5", + kind: "subset", + }); + expect(parseMeasureRange("126-129")).toEqual({ start: 126, end: 129 }); + expect(parseMeasureRange("0")).toEqual({ start: 0, end: 0 }); + }); +}); diff --git a/packages/drill-importers/src/coordinate-sheet.ts b/packages/drill-importers/src/coordinate-sheet.ts new file mode 100644 index 00000000..b1713013 --- /dev/null +++ b/packages/drill-importers/src/coordinate-sheet.ts @@ -0,0 +1,750 @@ +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + formatSetName, + parseDrillDocument, + type DrillDocument, + type DrillEntity, + type DrillPosition, + type DrillSet, + type MeasureRange, +} from "@eight2five/drill-schema"; + +import type { + CoordinateSheetImportOptions, + CoordinateSheetImportResult, + ExtractedPdfPage, + ExtractedPdfTextItem, + ImportDiagnostic, + ParsedCoordinateRow, + ParsedCoordinateSheet, + ParsedSetIdentity, +} from "./types"; + +const IMPORTER_NAME = "@eight2five/drill-importers/coordinate-sheet"; +const IMPORTER_VERSION = "1"; +const LINE_Y_TOLERANCE = 2.5; +const SHEET_ANCHOR_DEDUPLICATION = 24; +const HEADER_FIELD_PATTERN = + /Performer:\s*(.*?)\s+Symbol:\s*(.*?)\s+Label:\s*(.*?)\s+ID:\s*([0-9]+)/i; + +interface TextLine { + readonly y: number; + readonly items: readonly ExtractedPdfTextItem[]; + readonly text: string; +} + +type ColumnKey = "set" | "title" | "measure" | "counts" | "side" | "frontBack"; + +interface ColumnAnchor { + readonly key: ColumnKey; + readonly x: number; +} + +interface SheetSlice { + readonly pageNumber: number; + readonly sheetIndex: number; + readonly items: readonly ExtractedPdfTextItem[]; +} + +interface ParsedSheetResult { + readonly sheet?: ParsedCoordinateSheet; + readonly diagnostics: readonly ImportDiagnostic[]; +} + +/** + * Parse position-aware PDF text extracted from Pyware-style coordinate sheets + * into the portable Eight2Five drill document. The importer is intentionally + * independent of PDF.js; browser and native extraction layers only need to + * supply the generic item geometry in ExtractedPdfPage. + */ +export function importCoordinateSheetPages( + pages: readonly ExtractedPdfPage[], + options: CoordinateSheetImportOptions, +): CoordinateSheetImportResult { + const diagnostics: ImportDiagnostic[] = []; + const sheets: ParsedCoordinateSheet[] = []; + + for (const page of pages) { + for (const slice of splitPageIntoSheets(page)) { + const parsed = parseCoordinateSheetSlice(slice); + diagnostics.push(...parsed.diagnostics); + if (parsed.sheet) sheets.push(parsed.sheet); + } + } + + if (sheets.length === 0) { + diagnostics.push({ + severity: "error", + code: "NO_COORDINATE_SHEETS", + message: "No coordinate-sheet tables were found in the PDF text.", + }); + return { sheets, diagnostics }; + } + + const document = buildDrillDocument(sheets, options, diagnostics); + if (!document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) { + return { sheets, diagnostics }; + } + + try { + return { + sheets, + diagnostics, + document: parseDrillDocument(document), + }; + } catch (cause) { + diagnostics.push({ + severity: "error", + code: "PORTABLE_SCHEMA_VALIDATION_FAILED", + message: + cause instanceof Error + ? `Parsed coordinate sheets did not satisfy the portable drill schema: ${cause.message}` + : "Parsed coordinate sheets did not satisfy the portable drill schema.", + }); + return { sheets, diagnostics }; + } +} + +export function parseCoordinateSheetPage( + page: ExtractedPdfPage, +): readonly ParsedSheetResult[] { + return splitPageIntoSheets(page).map(parseCoordinateSheetSlice); +} + +function splitPageIntoSheets(page: ExtractedPdfPage): readonly SheetSlice[] { + const usefulItems = page.items.filter((item) => item.text.trim().length > 0); + if (usefulItems.length === 0) return []; + + let anchors = distinctSortedX( + usefulItems + .filter((item) => /\bPerformer\s*:/i.test(item.text)) + .map((item) => item.x), + ); + if (anchors.length < 2) { + anchors = distinctSortedX( + usefulItems + .filter((item) => /^\s*Set(?:\s|$)/i.test(item.text)) + .map((item) => item.x), + ); + } + if (anchors.length === 0) anchors = [Math.min(...usefulItems.map((item) => item.x))]; + + // Sheet anchors are left-edge origins, not centers. A two-up sheet can use + // most of the horizontal distance before the next origin, so midpoint + // partitioning would incorrectly steal the right-side coordinate columns. + return anchors + .map((anchor, sheetIndex) => { + const minX = sheetIndex === 0 ? Number.NEGATIVE_INFINITY : anchor - 0.5; + const maxX = + sheetIndex === anchors.length - 1 + ? Number.POSITIVE_INFINITY + : anchors[sheetIndex + 1] - 0.5; + return { + pageNumber: page.pageNumber, + sheetIndex, + items: usefulItems.filter((item) => item.x >= minX && item.x < maxX), + } satisfies SheetSlice; + }) + .filter((slice) => slice.items.length > 0); +} + +function distinctSortedX(values: readonly number[]): number[] { + const sorted = [...values].sort((left, right) => left - right); + const distinct: number[] = []; + for (const value of sorted) { + const previous = distinct.at(-1); + if (previous === undefined || Math.abs(value - previous) > SHEET_ANCHOR_DEDUPLICATION) { + distinct.push(value); + } + } + return distinct; +} + +function parseCoordinateSheetSlice(slice: SheetSlice): ParsedSheetResult { + const diagnostics: ImportDiagnostic[] = []; + const lines = groupTextLines(slice.items); + const headerIndex = lines.findIndex(isTableHeaderLine); + if (headerIndex < 0) { + return { + diagnostics: [ + { + severity: "error", + code: "TABLE_HEADER_NOT_FOUND", + message: "Could not find a Set/Measure/Counts coordinate table header.", + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + }, + ], + }; + } + + const headerText = lines + .slice(0, headerIndex) + .map((line) => line.text) + .join(" "); + const metadata = parseHeaderMetadata(headerText); + if (!metadata) { + diagnostics.push({ + severity: "error", + code: "PERFORMER_HEADER_NOT_FOUND", + message: "Could not parse the Performer / Symbol / Label / ID header.", + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + }); + return { diagnostics }; + } + + const anchors = deriveColumnAnchors(lines[headerIndex]); + const rows: ParsedCoordinateRow[] = []; + for (const line of lines.slice(headerIndex + 1)) { + if (/\bPrinted\s*:/i.test(line.text) || /^Page\s+\d+/i.test(line.text)) continue; + const parsed = anchors.length >= 5 ? parsePositionedRow(line, anchors) : parseFlatRow(line.text); + if (parsed === undefined) continue; + if (typeof parsed === "string") { + diagnostics.push({ + severity: "error", + code: "ROW_PARSE_FAILED", + message: parsed, + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + rowText: line.text, + }); + continue; + } + rows.push(parsed); + } + + if (rows.length === 0) { + diagnostics.push({ + severity: "error", + code: "NO_COORDINATE_ROWS", + message: "The coordinate sheet header was found, but no set rows could be parsed.", + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + }); + return { diagnostics }; + } + + return { + diagnostics, + sheet: { + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + ...metadata, + rows, + }, + }; +} + +function groupTextLines(items: readonly ExtractedPdfTextItem[]): readonly TextLine[] { + const sorted = [...items].sort((left, right) => { + const yDifference = right.y - left.y; + return Math.abs(yDifference) > LINE_Y_TOLERANCE ? yDifference : left.x - right.x; + }); + const groups: { y: number; items: ExtractedPdfTextItem[] }[] = []; + for (const item of sorted) { + let group = groups.find((candidate) => Math.abs(candidate.y - item.y) <= LINE_Y_TOLERANCE); + if (!group) { + group = { y: item.y, items: [] }; + groups.push(group); + } + group.items.push(item); + group.y = + group.items.reduce((total, current) => total + current.y, 0) / group.items.length; + } + return groups + .sort((left, right) => right.y - left.y) + .map((group) => { + const lineItems = [...group.items].sort((left, right) => left.x - right.x); + return { + y: group.y, + items: lineItems, + text: joinTextItems(lineItems), + }; + }); +} + +function joinTextItems(items: readonly ExtractedPdfTextItem[]): string { + return items + .map((item) => item.text.trim()) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +function isTableHeaderLine(line: TextLine): boolean { + const text = line.text.toLowerCase(); + return ( + /\bset\b/.test(text) && + /\bcounts?\b/.test(text) && + /\bside\b/.test(text) && + /\bfront\b/.test(text) && + /\bback\b/.test(text) + ); +} + +function parseHeaderMetadata(text: string): Omit | undefined { + const match = text.match(HEADER_FIELD_PATTERN); + if (!match) return undefined; + const performerName = cleanOptionalText(match[1]); + const sourceSymbol = cleanOptionalText(match[2]) ?? "?"; + const sourceLabel = cleanOptionalText(match[3]); + const sourceId = cleanOptionalText(match[4]); + const displayLabel = composeDisplayLabel(sourceSymbol, sourceLabel, performerName); + const tailStart = (match.index ?? 0) + match[0].length; + const tail = cleanOptionalText( + text + .slice(tailStart) + .replace(/\bPrinted\s*:.*$/i, "") + .trim(), + ); + return { + ...(performerName && !/^\(unnamed\)$/i.test(performerName) + ? { performerName } + : {}), + sourceSymbol, + ...(sourceLabel ? { sourceLabel } : {}), + ...(sourceId ? { sourceId } : {}), + displayLabel, + ...(tail ? { showTitle: tail } : {}), + }; +} + +function cleanOptionalText(value: string | undefined): string | undefined { + const cleaned = value?.replace(/\s+/g, " ").trim(); + return cleaned ? cleaned : undefined; +} + +function composeDisplayLabel( + symbol: string, + label: string | undefined, + performerName: string | undefined, +): string { + if (label) { + if (symbol === "?") return label; + return label.startsWith(symbol) ? label : `${symbol}${label}`; + } + if (performerName && /^[^\s]{1,8}\d{1,4}$/.test(performerName)) return performerName; + return symbol; +} + +function deriveColumnAnchors(header: TextLine): readonly ColumnAnchor[] { + const candidates: ColumnAnchor[] = []; + for (const item of header.items) { + const text = item.text.toLowerCase().replace(/\s+/g, " ").trim(); + if (/^set\b/.test(text)) candidates.push({ key: "set", x: item.x }); + else if (/^title\b/.test(text)) candidates.push({ key: "title", x: item.x }); + else if (/^measure\b/.test(text)) candidates.push({ key: "measure", x: item.x }); + else if (/^counts?\b/.test(text)) candidates.push({ key: "counts", x: item.x }); + else if (/^side\b/.test(text)) candidates.push({ key: "side", x: item.x }); + else if (/^front\b/.test(text)) candidates.push({ key: "frontBack", x: item.x }); + } + const byKey = new Map(); + for (const candidate of candidates.sort((left, right) => left.x - right.x)) { + if (!byKey.has(candidate.key)) byKey.set(candidate.key, candidate); + } + return [...byKey.values()].sort((left, right) => left.x - right.x); +} + +function parsePositionedRow( + line: TextLine, + anchors: readonly ColumnAnchor[], +): ParsedCoordinateRow | string | undefined { + const columns = bucketLineByColumns(line, anchors); + const setText = columns.get("set")?.trim() ?? ""; + if (!looksLikeSetToken(setText)) return undefined; + const set = parseSetIdentity(setText); + if (typeof set === "string") return set; + + const countsText = columns.get("counts")?.trim() ?? ""; + const counts = parseCounts(countsText); + if (typeof counts === "string") return counts; + + const measureText = columns.get("measure")?.trim() ?? ""; + const measureRange = parseMeasureRange(measureText); + if (typeof measureRange === "string") return measureRange; + + const sideText = columns.get("side")?.trim() ?? ""; + const frontBackText = columns.get("frontBack")?.trim() ?? ""; + const xSteps = parseSideToSide(sideText); + if (typeof xSteps === "string") return xSteps; + const ySteps = parseFrontBack(frontBackText); + if (typeof ySteps === "string") return ySteps; + + return { + set, + countsFromPrevious: counts, + ...(measureRange ? { measureRange } : {}), + position: { xSteps, ySteps }, + rawText: line.text, + }; +} + +function bucketLineByColumns( + line: TextLine, + anchors: readonly ColumnAnchor[], +): ReadonlyMap { + const sorted = [...anchors].sort((left, right) => left.x - right.x); + const buckets = new Map(); + for (const item of line.items) { + let index = 0; + for (let anchorIndex = 0; anchorIndex < sorted.length - 1; anchorIndex += 1) { + const boundary = (sorted[anchorIndex].x + sorted[anchorIndex + 1].x) / 2; + if (item.x >= boundary) index = anchorIndex + 1; + else break; + } + const key = sorted[index].key; + const bucket = buckets.get(key) ?? []; + bucket.push(item); + buckets.set(key, bucket); + } + return new Map( + [...buckets.entries()].map(([key, items]) => [key, joinTextItems(items)]), + ); +} + +/** Fallback for extractors that provide a whole row as one text fragment. */ +function parseFlatRow(text: string): ParsedCoordinateRow | string | undefined { + const normalized = text.replace(/\s+/g, " ").trim(); + const setMatch = normalized.match(/^(\d+(?:[A-Z]|\.[0-9]+)?)\s+(.*)$/); + if (!setMatch) return undefined; + const set = parseSetIdentity(setMatch[1]); + if (typeof set === "string") return set; + + const sideStart = setMatch[2].search(/(?:Side\s*[12]\s*:|On\s+(?:\d+\s*(?:yd\s*ln|yard\s*line)|50\b))/i); + if (sideStart < 0) return `Could not find the side-to-side coordinate in row: ${normalized}`; + const prefix = setMatch[2].slice(0, sideStart).trim(); + const coordinateTail = setMatch[2].slice(sideStart).trim(); + const frontStart = coordinateTail.search( + /(?:\bOn\s+(?:Front|Back|Home|Visitor)|\b[0-9]+(?:\.[0-9]+)?\s*(?:steps?)?\s*(?:Behind|In\s+Front\s+Of)\s+(?:Front|Back|Home|Visitor))/i, + ); + if (frontStart < 0) return `Could not find the front-to-back coordinate in row: ${normalized}`; + const sideText = coordinateTail.slice(0, frontStart).trim(); + const frontBackText = coordinateTail.slice(frontStart).trim(); + + const prefixTokens = prefix.split(" ").filter(Boolean); + let countsIndex = -1; + for (let index = prefixTokens.length - 1; index >= 0; index -= 1) { + if (/^\d+$/.test(prefixTokens[index])) { + countsIndex = index; + break; + } + } + if (countsIndex < 0) return `Could not find whole-number counts in row: ${normalized}`; + const counts = parseCounts(prefixTokens[countsIndex]); + if (typeof counts === "string") return counts; + const beforeCounts = prefixTokens.slice(0, countsIndex); + const measureToken = [...beforeCounts].reverse().find((token) => /^\d+(?:[-–]\d+)?$/.test(token)); + const measureRange = parseMeasureRange(measureToken ?? ""); + if (typeof measureRange === "string") return measureRange; + const xSteps = parseSideToSide(sideText); + if (typeof xSteps === "string") return xSteps; + const ySteps = parseFrontBack(frontBackText); + if (typeof ySteps === "string") return ySteps; + return { + set, + countsFromPrevious: counts, + ...(measureRange ? { measureRange } : {}), + position: { xSteps, ySteps }, + rawText: normalized, + }; +} + +function looksLikeSetToken(value: string): boolean { + return /^\d+(?:[A-Z]|\.[0-9]+)?$/.test(value.trim()); +} + +export function parseSetIdentity(value: string): ParsedSetIdentity | string { + const match = value.trim().match(/^(\d+)([A-Z]|\.[0-9]+)?$/); + if (!match) return `Invalid set identifier "${value}".`; + const number = Number(match[1]); + if (!Number.isSafeInteger(number)) return `Set number "${match[1]}" is too large.`; + const suffix = match[2]; + return suffix + ? { number, suffix, kind: "subset" } + : { number, kind: "set" }; +} + +function parseCounts(value: string): number | string { + if (!/^\d+$/.test(value)) return `Counts must be a non-negative whole number; received "${value}".`; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : `Counts value "${value}" is too large.`; +} + +export function parseMeasureRange(value: string): MeasureRange | undefined | string { + const normalized = value.trim(); + if (!normalized || normalized === "-" || normalized === "—") return undefined; + const match = normalized.match(/^(\d+)(?:\s*[-–]\s*(\d+))?$/); + if (!match) return `Invalid measure value "${value}".`; + const start = Number(match[1]); + const end = Number(match[2] ?? match[1]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) { + return `Measure value "${value}" is too large.`; + } + if (end < start) return `Measure range "${value}" ends before it starts.`; + return { start, end }; +} + +export function parseSideToSide(value: string): number | string { + const normalized = normalizeCoordinateText(value); + const goalLine = /\bgoal\s*line\b/i.test(normalized); + const yardMatch = normalized.match( + /\b(?:on|inside|outside)\s+(50|45|40|35|30|25|20|15|10|5|0)(?:\s*(?:yd\s*ln|yard\s*line))?\b/i, + ); + const yardLine = goalLine ? 0 : yardMatch ? Number(yardMatch[1]) : undefined; + if (yardLine === undefined) return `Could not parse yard-line reference "${value}".`; + + const sideMatch = normalized.match(/\bside\s*([12])\s*:/i); + const side = sideMatch ? Number(sideMatch[1]) : undefined; + const baseMagnitude = ((50 - yardLine) / 5) * 8; + if (/\bon\b/i.test(normalized) && !/\b(?:inside|outside)\b/i.test(normalized)) { + if (yardLine === 50) return 0; + if (side !== 1 && side !== 2) { + return `Yard line ${yardLine} requires Side 1 or Side 2 in "${value}".`; + } + return side === 1 ? -baseMagnitude : baseMagnitude; + } + + const offsetMatch = normalized.match( + /([0-9]+(?:\.[0-9]+)?)\s*(?:steps?)?\s*(inside|outside)\b/i, + ); + if (!offsetMatch || (side !== 1 && side !== 2)) { + return `Could not parse side-to-side coordinate "${value}".`; + } + const offset = Number(offsetMatch[1]); + const relation = offsetMatch[2].toLowerCase(); + const base = side === 1 ? -baseMagnitude : baseMagnitude; + const towardCenter = relation === "inside"; + return side === 1 + ? base + (towardCenter ? offset : -offset) + : base + (towardCenter ? -offset : offset); +} + +export function parseFrontBack(value: string): number | string { + const normalized = normalizeCoordinateText(value); + const reference = parseFrontBackReference(normalized); + if (!reference) return `Could not parse front/back reference "${value}".`; + const base = reference.ySteps; + if (/^\s*on\b/i.test(normalized)) return base; + const movement = normalized.match( + /^\s*([0-9]+(?:\.[0-9]+)?)\s*(?:steps?)?\s*(behind|in\s+front\s+of)\b/i, + ); + if (!movement) return `Could not parse front-to-back coordinate "${value}".`; + const offset = Number(movement[1]); + return /^behind$/i.test(movement[2]) ? base + offset : base - offset; +} + +function normalizeCoordinateText(value: string): string { + return value + .replace(/\(\s*HS\s*\)/gi, "") + .replace(/\s+/g, " ") + .trim(); +} + +function parseFrontBackReference( + value: string, +): { readonly name: string; readonly ySteps: number } | undefined { + const references = [ + { pattern: /\b(?:front|home)\s+(?:side\s*line|sideline)\b/i, name: "front-sideline", ySteps: 0 }, + { pattern: /\bfront\s+hash\b/i, name: "front-hash", ySteps: 28 }, + { pattern: /\bback\s+hash\b/i, name: "back-hash", ySteps: 56 }, + { pattern: /\b(?:back|visitor)\s+(?:side\s*line|sideline)\b/i, name: "back-sideline", ySteps: 84 }, + ] as const; + return references.find((reference) => reference.pattern.test(value)); +} + +function buildDrillDocument( + sheets: readonly ParsedCoordinateSheet[], + options: CoordinateSheetImportOptions, + diagnostics: ImportDiagnostic[], +): DrillDocument | undefined { + const canonical = sheets[0]; + const canonicalSets: DrillSet[] = canonical.rows.map((row, index) => ({ + id: index, + number: row.set.number, + ...(row.set.suffix ? { suffix: row.set.suffix } : {}), + kind: row.set.kind, + countsFromPrevious: row.countsFromPrevious, + ...(row.measureRange ? { measureRange: row.measureRange } : {}), + })); + + for (const [sheetIndex, sheet] of sheets.entries()) { + if (sheet.rows.length !== canonical.rows.length) { + diagnostics.push({ + severity: "error", + code: "SET_COUNT_MISMATCH", + message: `${sheet.displayLabel} has ${sheet.rows.length} rows; expected ${canonical.rows.length}.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + }); + continue; + } + for (let index = 0; index < canonical.rows.length; index += 1) { + const expected = canonical.rows[index]; + const actual = sheet.rows[index]; + if (!sameSetIdentity(expected.set, actual.set)) { + diagnostics.push({ + severity: "error", + code: "SET_IDENTITY_MISMATCH", + message: `${sheet.displayLabel} row ${index + 1} is Set ${formatSetName(actual.set)}, expected Set ${formatSetName(expected.set)}.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + rowText: actual.rawText, + }); + } + if (actual.countsFromPrevious !== expected.countsFromPrevious) { + diagnostics.push({ + severity: "error", + code: "COUNTS_MISMATCH", + message: `Set ${formatSetName(expected.set)} has inconsistent counts (${expected.countsFromPrevious} vs ${actual.countsFromPrevious}) on ${sheet.displayLabel}.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + rowText: actual.rawText, + }); + } + if (!sameMeasureRange(actual.measureRange, expected.measureRange)) { + diagnostics.push({ + severity: "error", + code: "MEASURE_MISMATCH", + message: `Set ${formatSetName(expected.set)} has inconsistent measure metadata on ${sheet.displayLabel}.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + rowText: actual.rawText, + }); + } + } + if (sheetIndex === 0 && sheet.rows[0]?.countsFromPrevious !== 0) { + diagnostics.push({ + severity: "error", + code: "FIRST_SET_COUNTS_NONZERO", + message: "The first imported set must have 0 counts from previous.", + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + rowText: sheet.rows[0]?.rawText, + }); + } + } + + const entityIds = assignEntityIds(sheets, diagnostics); + if (!entityIds) return undefined; + const labels = new Set(); + const entities: DrillEntity[] = []; + const positions: DrillPosition[] = []; + const references: NonNullable["references"]>[number][] = []; + + for (const [sheetIndex, sheet] of sheets.entries()) { + if (labels.has(sheet.displayLabel)) { + diagnostics.push({ + severity: "error", + code: "DUPLICATE_PERFORMER_LABEL", + message: `Performer label ${sheet.displayLabel} appears more than once.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + }); + continue; + } + labels.add(sheet.displayLabel); + const entityId = entityIds[sheetIndex]; + entities.push({ + id: entityId, + type: "performer", + symbol: sheet.sourceSymbol, + label: sheet.displayLabel, + ...(sheet.performerName ? { name: sheet.performerName } : {}), + }); + references.push({ + target: { type: "entity", entityId }, + page: sheet.pageNumber, + }); + for (const [setId, row] of sheet.rows.entries()) { + positions.push({ + entityId, + setId, + xSteps: row.position.xSteps, + ySteps: row.position.ySteps, + }); + references.push({ + target: { type: "position", entityId, setId }, + page: sheet.pageNumber, + rawText: row.rawText, + }); + } + } + + return { + schema: DRILL_SCHEMA_URL, + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: options.title.trim() || "Imported Drill", + createdAt: options.createdAt, + }, + field: { type: "preset", preset: "football-nfhs" }, + entities, + sets: canonicalSets, + positions, + provenance: { + source: { + kind: "coordinate-sheet-pdf", + ...(options.fileName ? { fileName: options.fileName } : {}), + }, + importer: { name: IMPORTER_NAME, version: IMPORTER_VERSION }, + importedAt: options.createdAt, + references, + }, + extensions: { + "eight2five.coordinateSheet": { + sheets: sheets.map((sheet, index) => ({ + entityId: entityIds[index], + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + ...(sheet.sourceId ? { sourceId: sheet.sourceId } : {}), + ...(sheet.sourceLabel ? { sourceLabel: sheet.sourceLabel } : {}), + ...(sheet.showTitle ? { showTitle: sheet.showTitle } : {}), + })), + }, + }, + }; +} + +function assignEntityIds( + sheets: readonly ParsedCoordinateSheet[], + diagnostics: ImportDiagnostic[], +): readonly number[] | undefined { + const sourceIds = sheets.map((sheet) => { + if (!sheet.sourceId || !/^\d+$/.test(sheet.sourceId)) return undefined; + const parsed = Number(sheet.sourceId); + return Number.isSafeInteger(parsed) ? parsed : undefined; + }); + const validSourceIds = sourceIds.every( + (id): id is number => id !== undefined, + ); + if (validSourceIds && new Set(sourceIds).size === sourceIds.length) return sourceIds; + + if (sheets.some((sheet) => sheet.sourceId)) { + diagnostics.push({ + severity: "warning", + code: "SOURCE_IDS_REASSIGNED", + message: + "One or more source performer IDs were missing, duplicated, or outside JavaScript's safe integer range; portable IDs were assigned sequentially.", + }); + } + return sheets.map((_, index) => index + 1); +} + +function sameSetIdentity(left: ParsedSetIdentity, right: ParsedSetIdentity): boolean { + return ( + left.number === right.number && + (left.suffix ?? "") === (right.suffix ?? "") && + left.kind === right.kind + ); +} + +function sameMeasureRange( + left: MeasureRange | undefined, + right: MeasureRange | undefined, +): boolean { + if (!left || !right) return left === right; + return left.start === right.start && left.end === right.end; +} diff --git a/packages/drill-importers/src/index.ts b/packages/drill-importers/src/index.ts new file mode 100644 index 00000000..b9b499e6 --- /dev/null +++ b/packages/drill-importers/src/index.ts @@ -0,0 +1,2 @@ +export * from "./coordinate-sheet"; +export * from "./types"; diff --git a/packages/drill-importers/src/types.ts b/packages/drill-importers/src/types.ts new file mode 100644 index 00000000..85f4cdd4 --- /dev/null +++ b/packages/drill-importers/src/types.ts @@ -0,0 +1,70 @@ +import type { + DrillDocument, + DrillGridPoint, + MeasureRange, + SetKind, +} from "@eight2five/drill-schema"; + +export interface ExtractedPdfTextItem { + readonly text: string; + readonly x: number; + readonly y: number; + readonly width?: number; + readonly height?: number; +} + +export interface ExtractedPdfPage { + readonly pageNumber: number; + readonly width: number; + readonly height: number; + readonly items: readonly ExtractedPdfTextItem[]; +} + +export interface ParsedSetIdentity { + readonly number: number; + readonly suffix?: string; + readonly kind: SetKind; +} + +export interface ParsedCoordinateRow { + readonly set: ParsedSetIdentity; + readonly countsFromPrevious: number; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly rawText: string; +} + +export interface ParsedCoordinateSheet { + readonly pageNumber: number; + readonly sheetIndex: number; + readonly performerName?: string; + readonly sourceSymbol: string; + readonly sourceLabel?: string; + readonly sourceId?: string; + readonly displayLabel: string; + readonly showTitle?: string; + readonly rows: readonly ParsedCoordinateRow[]; +} + +export type ImportDiagnosticSeverity = "warning" | "error"; + +export interface ImportDiagnostic { + readonly severity: ImportDiagnosticSeverity; + readonly code: string; + readonly message: string; + readonly pageNumber?: number; + readonly sheetIndex?: number; + readonly rowText?: string; +} + +export interface CoordinateSheetImportOptions { + readonly title: string; + readonly fileName?: string; + readonly createdAt: string; +} + +export interface CoordinateSheetImportResult { + readonly document?: DrillDocument; + readonly sheets: readonly ParsedCoordinateSheet[]; + readonly diagnostics: readonly ImportDiagnostic[]; +} diff --git a/packages/drill-importers/tsconfig.json b/packages/drill-importers/tsconfig.json new file mode 100644 index 00000000..846c3924 --- /dev/null +++ b/packages/drill-importers/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["jest"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 70ff6a55..3049a380 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,8 @@ "paths": { "@eight2five/drill-schema": ["./packages/drill-schema/src/index"], "@eight2five/drill-schema/*": ["./packages/drill-schema/src/*"], + "@eight2five/drill-importers": ["./packages/drill-importers/src/index"], + "@eight2five/drill-importers/*": ["./packages/drill-importers/src/*"], "@eight2five/mobile": ["./packages/mobile/src/index"], "@eight2five/mobile/*": ["./packages/mobile/src/*"] } From 4a8e737de52d85d32eda51987ff2cb22775f1c35 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 15:00:26 -0500 Subject: [PATCH 030/101] fix(drill-importers): Honor field conventions --- package-lock.json | 2 +- packages/drill-importers/package.json | 2 +- .../src/__tests__/coordinate-sheet.test.ts | 6 ++ .../drill-importers/src/coordinate-sheet.ts | 65 ++++++++++++++----- packages/drill-importers/src/types.ts | 2 + 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea36bccd..ab6792f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18988,7 +18988,7 @@ "name": "@eight2five/drill-importers", "version": "0.0.0", "dependencies": { - "@eight2five/drill-schema": "*" + "@eight2five/drill-schema": "0.0.0" } }, "packages/drill-schema": { diff --git a/packages/drill-importers/package.json b/packages/drill-importers/package.json index b3336094..2ecd252e 100644 --- a/packages/drill-importers/package.json +++ b/packages/drill-importers/package.json @@ -12,7 +12,7 @@ "test": "jest src --watchAll=false --passWithNoTests --runInBand" }, "dependencies": { - "@eight2five/drill-schema": "*" + "@eight2five/drill-schema": "0.0.0" }, "jest": { "testEnvironment": "node", diff --git a/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts b/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts index 683cf6fe..1a09c599 100644 --- a/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts +++ b/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts @@ -175,6 +175,12 @@ describe("coordinate sheet importer", () => { expect(parseFrontBack("4.0 steps Behind Front Hash (HS)")).toBe(32); expect(parseFrontBack("3.5 steps In Front Of Back Hash (HS)")).toBe(52.5); expect(parseFrontBack("On Back Sideline")).toBe(84); + expect( + parseFrontBack("On Front Hash", { + type: "preset", + preset: "football-ncaa", + }), + ).toBe(32); }); test("keeps set identity and measure ranges structured", () => { diff --git a/packages/drill-importers/src/coordinate-sheet.ts b/packages/drill-importers/src/coordinate-sheet.ts index b1713013..ab7e82c2 100644 --- a/packages/drill-importers/src/coordinate-sheet.ts +++ b/packages/drill-importers/src/coordinate-sheet.ts @@ -2,8 +2,10 @@ import { DRILL_SCHEMA_URL, DRILL_SCHEMA_VERSION, formatSetName, + getGridReference, parseDrillDocument, type DrillDocument, + type FieldDefinition, type DrillEntity, type DrillPosition, type DrillSet, @@ -23,6 +25,10 @@ import type { const IMPORTER_NAME = "@eight2five/drill-importers/coordinate-sheet"; const IMPORTER_VERSION = "1"; +const DEFAULT_FIELD: FieldDefinition = { + type: "preset", + preset: "football-nfhs", +}; const LINE_Y_TOLERANCE = 2.5; const SHEET_ANCHOR_DEDUPLICATION = 24; const HEADER_FIELD_PATTERN = @@ -64,10 +70,11 @@ export function importCoordinateSheetPages( ): CoordinateSheetImportResult { const diagnostics: ImportDiagnostic[] = []; const sheets: ParsedCoordinateSheet[] = []; + const field = options.field ?? DEFAULT_FIELD; for (const page of pages) { for (const slice of splitPageIntoSheets(page)) { - const parsed = parseCoordinateSheetSlice(slice); + const parsed = parseCoordinateSheetSlice(slice, field); diagnostics.push(...parsed.diagnostics); if (parsed.sheet) sheets.push(parsed.sheet); } @@ -108,8 +115,11 @@ export function importCoordinateSheetPages( export function parseCoordinateSheetPage( page: ExtractedPdfPage, + field: FieldDefinition = DEFAULT_FIELD, ): readonly ParsedSheetResult[] { - return splitPageIntoSheets(page).map(parseCoordinateSheetSlice); + return splitPageIntoSheets(page).map((slice) => + parseCoordinateSheetSlice(slice, field), + ); } function splitPageIntoSheets(page: ExtractedPdfPage): readonly SheetSlice[] { @@ -161,7 +171,10 @@ function distinctSortedX(values: readonly number[]): number[] { return distinct; } -function parseCoordinateSheetSlice(slice: SheetSlice): ParsedSheetResult { +function parseCoordinateSheetSlice( + slice: SheetSlice, + field: FieldDefinition, +): ParsedSheetResult { const diagnostics: ImportDiagnostic[] = []; const lines = groupTextLines(slice.items); const headerIndex = lines.findIndex(isTableHeaderLine); @@ -199,7 +212,10 @@ function parseCoordinateSheetSlice(slice: SheetSlice): ParsedSheetResult { const rows: ParsedCoordinateRow[] = []; for (const line of lines.slice(headerIndex + 1)) { if (/\bPrinted\s*:/i.test(line.text) || /^Page\s+\d+/i.test(line.text)) continue; - const parsed = anchors.length >= 5 ? parsePositionedRow(line, anchors) : parseFlatRow(line.text); + const parsed = + anchors.length >= 5 + ? parsePositionedRow(line, anchors, field) + : parseFlatRow(line.text, field); if (parsed === undefined) continue; if (typeof parsed === "string") { diagnostics.push({ @@ -351,6 +367,7 @@ function deriveColumnAnchors(header: TextLine): readonly ColumnAnchor[] { function parsePositionedRow( line: TextLine, anchors: readonly ColumnAnchor[], + field: FieldDefinition, ): ParsedCoordinateRow | string | undefined { const columns = bucketLineByColumns(line, anchors); const setText = columns.get("set")?.trim() ?? ""; @@ -370,7 +387,7 @@ function parsePositionedRow( const frontBackText = columns.get("frontBack")?.trim() ?? ""; const xSteps = parseSideToSide(sideText); if (typeof xSteps === "string") return xSteps; - const ySteps = parseFrontBack(frontBackText); + const ySteps = parseFrontBack(frontBackText, field); if (typeof ySteps === "string") return ySteps; return { @@ -406,7 +423,10 @@ function bucketLineByColumns( } /** Fallback for extractors that provide a whole row as one text fragment. */ -function parseFlatRow(text: string): ParsedCoordinateRow | string | undefined { +function parseFlatRow( + text: string, + field: FieldDefinition, +): ParsedCoordinateRow | string | undefined { const normalized = text.replace(/\s+/g, " ").trim(); const setMatch = normalized.match(/^(\d+(?:[A-Z]|\.[0-9]+)?)\s+(.*)$/); if (!setMatch) return undefined; @@ -441,7 +461,7 @@ function parseFlatRow(text: string): ParsedCoordinateRow | string | undefined { if (typeof measureRange === "string") return measureRange; const xSteps = parseSideToSide(sideText); if (typeof xSteps === "string") return xSteps; - const ySteps = parseFrontBack(frontBackText); + const ySteps = parseFrontBack(frontBackText, field); if (typeof ySteps === "string") return ySteps; return { set, @@ -522,9 +542,12 @@ export function parseSideToSide(value: string): number | string { : base + (towardCenter ? -offset : offset); } -export function parseFrontBack(value: string): number | string { +export function parseFrontBack( + value: string, + field: FieldDefinition = DEFAULT_FIELD, +): number | string { const normalized = normalizeCoordinateText(value); - const reference = parseFrontBackReference(normalized); + const reference = parseFrontBackReference(normalized, field); if (!reference) return `Could not parse front/back reference "${value}".`; const base = reference.ySteps; if (/^\s*on\b/i.test(normalized)) return base; @@ -545,14 +568,26 @@ function normalizeCoordinateText(value: string): string { function parseFrontBackReference( value: string, + field: FieldDefinition, ): { readonly name: string; readonly ySteps: number } | undefined { const references = [ - { pattern: /\b(?:front|home)\s+(?:side\s*line|sideline)\b/i, name: "front-sideline", ySteps: 0 }, - { pattern: /\bfront\s+hash\b/i, name: "front-hash", ySteps: 28 }, - { pattern: /\bback\s+hash\b/i, name: "back-hash", ySteps: 56 }, - { pattern: /\b(?:back|visitor)\s+(?:side\s*line|sideline)\b/i, name: "back-sideline", ySteps: 84 }, + { + pattern: /\b(?:front|home)\s+(?:side\s*line|sideline)\b/i, + id: "front-sideline", + }, + { pattern: /\bfront\s+hash\b/i, id: "front-hash" }, + { pattern: /\bback\s+hash\b/i, id: "back-hash" }, + { + pattern: /\b(?:back|visitor)\s+(?:side\s*line|sideline)\b/i, + id: "back-sideline", + }, ] as const; - return references.find((reference) => reference.pattern.test(value)); + const matched = references.find((reference) => reference.pattern.test(value)); + if (!matched) return undefined; + const gridReference = getGridReference(field, matched.id); + return gridReference + ? { name: matched.id, ySteps: gridReference.coordinateSteps } + : undefined; } function buildDrillDocument( @@ -680,7 +715,7 @@ function buildDrillDocument( title: options.title.trim() || "Imported Drill", createdAt: options.createdAt, }, - field: { type: "preset", preset: "football-nfhs" }, + field: options.field ?? DEFAULT_FIELD, entities, sets: canonicalSets, positions, diff --git a/packages/drill-importers/src/types.ts b/packages/drill-importers/src/types.ts index 85f4cdd4..aae420c6 100644 --- a/packages/drill-importers/src/types.ts +++ b/packages/drill-importers/src/types.ts @@ -1,6 +1,7 @@ import type { DrillDocument, DrillGridPoint, + FieldDefinition, MeasureRange, SetKind, } from "@eight2five/drill-schema"; @@ -61,6 +62,7 @@ export interface CoordinateSheetImportOptions { readonly title: string; readonly fileName?: string; readonly createdAt: string; + readonly field?: FieldDefinition; } export interface CoordinateSheetImportResult { From 84e215a2213b3a5e5cc101262607bea5cbd69abd Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 15:00:52 -0500 Subject: [PATCH 031/101] chore(workspaces): Pin internal drill dependencies --- package-lock.json | 2 +- packages/mobile/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab6792f7..21e6e4eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19002,7 +19002,7 @@ "name": "@eight2five/mobile", "version": "0.0.0", "dependencies": { - "@eight2five/drill-schema": "*", + "@eight2five/drill-schema": "0.0.0", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6b061af1..e5dd095d 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -27,7 +27,7 @@ "react-native": "0.86.2" }, "dependencies": { - "@eight2five/drill-schema": "*", + "@eight2five/drill-schema": "0.0.0", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", From 90f7c9d1665a034e01d2a53bf124647bf576fab9 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 15:02:09 -0500 Subject: [PATCH 032/101] feat(drill-converter): Add browser PDF converter --- apps/drill-converter/app.config.ts | 19 + apps/drill-converter/app/_layout.tsx | 15 + apps/drill-converter/app/index.tsx | 13 + apps/drill-converter/metro.config.js | 19 + apps/drill-converter/package.json | 50 +++ .../src/components/details-section.tsx | 157 +++++++ .../components/entity-settings-section.tsx | 401 ++++++++++++++++++ .../src/components/file-section.tsx | 124 ++++++ .../src/components/preview-section.tsx | 253 +++++++++++ apps/drill-converter/src/converter-screen.tsx | 113 +++++ .../src/converter/__tests__/settings.test.ts | 135 ++++++ .../src/converter/download.web.ts | 23 + .../drill-converter/src/converter/settings.ts | 357 ++++++++++++++++ .../src/converter/use-converter-controller.ts | 234 ++++++++++ .../src/pdf/pdf-text-extractor.web.ts | 134 ++++++ apps/drill-converter/src/ui/form-controls.tsx | 382 +++++++++++++++++ apps/drill-converter/src/ui/theme.ts | 34 ++ apps/drill-converter/tsconfig.json | 14 + package-lock.json | 29 ++ package.json | 2 + 20 files changed, 2508 insertions(+) create mode 100644 apps/drill-converter/app.config.ts create mode 100644 apps/drill-converter/app/_layout.tsx create mode 100644 apps/drill-converter/app/index.tsx create mode 100644 apps/drill-converter/metro.config.js create mode 100644 apps/drill-converter/package.json create mode 100644 apps/drill-converter/src/components/details-section.tsx create mode 100644 apps/drill-converter/src/components/entity-settings-section.tsx create mode 100644 apps/drill-converter/src/components/file-section.tsx create mode 100644 apps/drill-converter/src/components/preview-section.tsx create mode 100644 apps/drill-converter/src/converter-screen.tsx create mode 100644 apps/drill-converter/src/converter/__tests__/settings.test.ts create mode 100644 apps/drill-converter/src/converter/download.web.ts create mode 100644 apps/drill-converter/src/converter/settings.ts create mode 100644 apps/drill-converter/src/converter/use-converter-controller.ts create mode 100644 apps/drill-converter/src/pdf/pdf-text-extractor.web.ts create mode 100644 apps/drill-converter/src/ui/form-controls.tsx create mode 100644 apps/drill-converter/src/ui/theme.ts create mode 100644 apps/drill-converter/tsconfig.json diff --git a/apps/drill-converter/app.config.ts b/apps/drill-converter/app.config.ts new file mode 100644 index 00000000..ea9d2d83 --- /dev/null +++ b/apps/drill-converter/app.config.ts @@ -0,0 +1,19 @@ +import type { ExpoConfig } from "expo/config"; + +const config: ExpoConfig = { + name: "Eight2Five Drill Converter", + slug: "eight2five-drill-converter", + version: "0.0.0", + platforms: ["web"], + userInterfaceStyle: "automatic", + web: { + bundler: "metro", + output: "static", + }, + plugins: ["expo-router"], + experiments: { + typedRoutes: true, + }, +}; + +export default config; diff --git a/apps/drill-converter/app/_layout.tsx b/apps/drill-converter/app/_layout.tsx new file mode 100644 index 00000000..9ad258a1 --- /dev/null +++ b/apps/drill-converter/app/_layout.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { Stack } from "expo-router"; + +export default function RootLayout() { + return ( + + ); +} diff --git a/apps/drill-converter/app/index.tsx b/apps/drill-converter/app/index.tsx new file mode 100644 index 00000000..13eb1a52 --- /dev/null +++ b/apps/drill-converter/app/index.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { Stack } from "expo-router"; + +import { ConverterScreen } from "../src/converter-screen"; + +export default function IndexRoute() { + return ( + <> + + + + ); +} diff --git a/apps/drill-converter/metro.config.js b/apps/drill-converter/metro.config.js new file mode 100644 index 00000000..d84b494d --- /dev/null +++ b/apps/drill-converter/metro.config.js @@ -0,0 +1,19 @@ +const { getDefaultConfig } = require("expo/metro-config"); +const path = require("path"); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(__dirname, "../.."); +const config = getDefaultConfig(projectRoot); + +config.watchFolders = Array.from( + new Set([...(config.watchFolders ?? []), workspaceRoot]) +); +config.resolver.nodeModulesPaths = Array.from( + new Set([ + ...(config.resolver?.nodeModulesPaths ?? []), + path.resolve(projectRoot, "node_modules"), + path.resolve(workspaceRoot, "node_modules"), + ]) +); + +module.exports = config; diff --git a/apps/drill-converter/package.json b/apps/drill-converter/package.json new file mode 100644 index 00000000..09d060a4 --- /dev/null +++ b/apps/drill-converter/package.json @@ -0,0 +1,50 @@ +{ + "name": "eight2five-drill-converter", + "version": "0.0.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start --web", + "web": "expo start --web", + "build:web": "expo export --platform web", + "type-check": "tsc --noEmit", + "lint": "expo lint", + "lint:fix": "expo lint --fix", + "test": "jest --watchAll=false --passWithNoTests --runInBand" + }, + "dependencies": { + "@eight2five/drill-importers": "0.0.0", + "@eight2five/drill-schema": "0.0.0", + "@expo/metro-runtime": "~57.0.8", + "expo": "~57.0.9", + "expo-document-picker": "~57.0.1", + "expo-router": "~57.0.9", + "react": "19.2.3", + "react-dom": "^19.2.3", + "react-native": "0.86.2", + "react-native-safe-area-context": "~5.7.0", + "react-native-web": "^0.21.2" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/react": "~19.2.10", + "eslint": "^9.0.0", + "eslint-config-expo": "~57.0.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "jest-expo": "~57.0.3", + "prettier": "^3.6.2", + "typescript": "~6.0.3" + }, + "jest": { + "preset": "jest-expo", + "testEnvironment": "node", + "moduleNameMapper": { + "^@eight2five/drill-schema$": "/../../packages/drill-schema/src/index.ts", + "^@eight2five/drill-schema/(.*)$": "/../../packages/drill-schema/src/$1", + "^@eight2five/drill-importers$": "/../../packages/drill-importers/src/index.ts", + "^@eight2five/drill-importers/(.*)$": "/../../packages/drill-importers/src/$1" + } + } +} diff --git a/apps/drill-converter/src/components/details-section.tsx b/apps/drill-converter/src/components/details-section.tsx new file mode 100644 index 00000000..dc32bb00 --- /dev/null +++ b/apps/drill-converter/src/components/details-section.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { Text, View } from "react-native"; +import { + getFieldPreset, + getGridReference, + type FieldPresetId, +} from "@eight2five/drill-schema"; + +import { + ChoiceChips, + Disclosure, + FormField, + SectionCard, +} from "../ui/form-controls"; +import { colors, radius, spacing } from "../ui/theme"; +import { + FIELD_PRESET_OPTIONS, + type ConverterSettings, +} from "../converter/settings"; + +const FIELD_OPTIONS = Object.freeze([ + ...FIELD_PRESET_OPTIONS, + { value: "custom", label: "Custom" }, +] as const); + +export function DetailsSection({ + settings, + errors, + onUpdate, +}: { + readonly settings: ConverterSettings; + readonly errors: readonly string[]; + readonly onUpdate: (patch: Partial) => void; +}) { + const titleError = errors.find((error) => error.includes("title")); + const customFieldError = errors.find((error) => + error.startsWith("Custom field"), + ); + return ( + + onUpdate({ title })} + placeholder="Part 4" + error={titleError} + /> + + onUpdate({ fieldMode })} + /> + + {settings.fieldMode === "custom" ? ( + onUpdate({ customFieldJson })} + multiline + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + error={customFieldError} + helper="Advanced: paste a v1 custom field object with matched physical and marching reference lines. The editor starts from NFHS geometry so you can change only what differs." + /> + ) : ( + + )} + + + onUpdate({ drillWriter })} + placeholder="Optional" + /> + onUpdate({ ensemble })} + placeholder="Optional" + /> + onUpdate({ description })} + multiline + placeholder="Optional notes about this movement" + /> + onUpdate({ lucideIcon })} + autoCapitalize="none" + autoCorrect={false} + placeholder="music-2" + helper="Optional kebab-case Lucide icon name stored as metadata. The converter does not download icon assets." + /> + + + ); +} + +function FieldPresetSummary({ preset }: { readonly preset: FieldPresetId }) { + const field = getFieldPreset(preset); + const frontHash = getGridReference({ type: "preset", preset }, "front-hash"); + const backHash = getGridReference({ type: "preset", preset }, "back-hash"); + return ( + + + {field.name} + + + Origin: center of the 50 on the front sideline · Side 1 is negative X · + Side 2 is positive X · backfield is positive Y. + + + Marching grid: Front sideline 0 · Front hash{" "} + {formatNumber(frontHash?.coordinateSteps)} · Back hash{" "} + {formatNumber(backHash?.coordinateSteps)} · Back sideline 84. + + + ); +} + +function formatNumber(value: number | undefined): string { + if (value === undefined) return "—"; + return Number.isInteger(value) + ? String(value) + : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); +} diff --git a/apps/drill-converter/src/components/entity-settings-section.tsx b/apps/drill-converter/src/components/entity-settings-section.tsx new file mode 100644 index 00000000..ca005632 --- /dev/null +++ b/apps/drill-converter/src/components/entity-settings-section.tsx @@ -0,0 +1,401 @@ +import React from "react"; +import { Pressable, Text, View } from "react-native"; + +import { + ChoiceChips, + Disclosure, + FormField, + SecondaryButton, + ToggleRow, +} from "../ui/form-controls"; +import { colors, radius, spacing } from "../ui/theme"; +import { + COLOR_PRESET_OPTIONS, + ENTITY_ICON_OPTIONS, + type ConverterSettings, + type EntityRuleDraft, +} from "../converter/settings"; + +const TARGET_OPTIONS = Object.freeze([ + { value: "symbol", label: "Symbol" }, + { value: "label", label: "Label" }, + { value: "id", label: "ID" }, +] as const); + +const LABEL_OPTIONS = Object.freeze([ + { value: "inherit", label: "Default" }, + { value: "visible", label: "Show" }, + { value: "hidden", label: "Hide" }, +] as const); + +export function EntitySettingsSection({ + settings, + availableSymbols, + errors, + onUpdate, + onTogglePropSymbol, + onAddRule, + onUpdateRule, + onRemoveRule, +}: { + readonly settings: ConverterSettings; + readonly availableSymbols: readonly string[]; + readonly errors: readonly string[]; + readonly onUpdate: (patch: Partial) => void; + readonly onTogglePropSymbol: (symbol: string) => void; + readonly onAddRule: (target?: EntityRuleDraft["target"]) => void; + readonly onUpdateRule: ( + id: string, + patch: Partial>, + ) => void; + readonly onRemoveRule: (id: string) => void; +}) { + const ruleErrors = errors.filter( + (error) => error.startsWith("Rule ") || error.startsWith("Duplicate "), + ); + return ( + + + + Entity type by symbol + + {availableSymbols.length === 0 ? ( + + Select and parse a PDF first. Every extracted symbol defaults to a + performer; mark prop symbols here when needed. + + ) : ( + + {availableSymbols.map((symbol) => ( + onTogglePropSymbol(symbol)} + /> + ))} + + )} + + + + + + Rules and overrides + + + Precedence is symbol → label → ID → explicit entity values. Leave a + field blank to inherit the broader rule or schema default. + + + + {settings.rules.map((rule, index) => ( + onUpdateRule(rule.id, patch)} + onRemove={() => onRemoveRule(rule.id)} + /> + ))} + + {ruleErrors.length > 0 ? ( + + {ruleErrors.map((error) => ( + + {error} + + ))} + + ) : null} + + + onAddRule("symbol")} + /> + onAddRule("label")} + /> + onAddRule("id")} + /> + + + + + onUpdate({ explicitStraightPaths }) + } + /> + + onUpdate({ includeSourceReferences }) + } + /> + + ); +} + +function RuleEditor({ + index, + rule, + availableSymbols, + onUpdate, + onRemove, +}: { + readonly index: number; + readonly rule: EntityRuleDraft; + readonly availableSymbols: readonly string[]; + readonly onUpdate: (patch: Partial>) => void; + readonly onRemove: () => void; +}) { + const colorMatchesPreset = COLOR_PRESET_OPTIONS.some( + (option) => option.value.toLowerCase() === rule.color.toLowerCase(), + ); + return ( + + + + Rule {index + 1} + + + + + + + onUpdate({ target, key: "" })} + /> + {rule.target === "symbol" && availableSymbols.length > 0 ? ( + ({ + value: symbol, + label: symbol, + })), + ]} + onChange={(key) => onUpdate({ key })} + /> + ) : null} + onUpdate({ key })} + autoCapitalize="none" + autoCorrect={false} + placeholder={ + rule.target === "symbol" + ? "B" + : rule.target === "label" + ? "B1" + : "1595433022185" + } + /> + + + onUpdate({ section })} + placeholder="Optional" + /> + + + onUpdate({ instrument })} + placeholder="Optional" + /> + + + + ({ + value: icon, + label: titleCase(icon), + })), + ]} + onChange={(icon) => onUpdate({ icon })} + /> + + + + Color + + + onUpdate({ color: "" })} + /> + {COLOR_PRESET_OPTIONS.map((option) => ( + onUpdate({ color: option.value })} + /> + ))} + + onUpdate({ color })} + autoCapitalize="none" + autoCorrect={false} + placeholder="#3c6ec8" + helper="Leave blank for the selected preset/default. Any six-digit hex color is valid." + /> + + + onUpdate({ labelVisibility })} + /> + + ); +} + +function ColorChip({ + label, + color, + selected, + onPress, +}: { + readonly label: string; + readonly color?: string; + readonly selected: boolean; + readonly onPress: () => void; +}) { + return ( + ({ + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + minHeight: 38, + borderWidth: 1, + borderColor: selected ? colors.accent : colors.borderStrong, + borderRadius: 999, + paddingHorizontal: 12, + backgroundColor: selected + ? colors.accentSoft + : pressed + ? colors.surfaceMuted + : colors.surface, + })} + > + {color ? ( + + ) : null} + + {label} + + + ); +} + +function titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} diff --git a/apps/drill-converter/src/components/file-section.tsx b/apps/drill-converter/src/components/file-section.tsx new file mode 100644 index 00000000..5936612b --- /dev/null +++ b/apps/drill-converter/src/components/file-section.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { ActivityIndicator, Text, View } from "react-native"; +import type { DocumentPickerAsset } from "expo-document-picker"; + +import { + PrimaryButton, + SecondaryButton, + SectionCard, +} from "../ui/form-controls"; +import { colors, radius, spacing } from "../ui/theme"; +import type { ConverterPhase } from "../converter/use-converter-controller"; + +export function FileSection({ + asset, + phase, + extractionError, + pageCount, + pdfJsVersion, + onPick, + onClear, +}: { + readonly asset?: DocumentPickerAsset; + readonly phase: ConverterPhase; + readonly extractionError?: string; + readonly pageCount: number; + readonly pdfJsVersion: string; + readonly onPick: () => void; + readonly onClear: () => void; +}) { + return ( + + {!asset ? ( + + + + ) : ( + + + + {asset.name} + + + {formatBytes(asset.size)} + {pageCount > 0 + ? ` · ${pageCount} PDF page${pageCount === 1 ? "" : "s"}` + : ""} + + + {phase === "extracting" ? ( + + + + Extracting browser text… + + + ) : null} + {extractionError ? ( + + + {extractionError} + + + ) : null} + + + + + + + + + + )} + + Text extraction uses PDF.js {pdfJsVersion}, loaded from a pinned + jsDelivr URL. Only the library is fetched; selected PDF bytes remain + local. + + + ); +} + +function formatBytes(value: number | undefined): string { + if (!value || value < 1) return "PDF file"; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/apps/drill-converter/src/components/preview-section.tsx b/apps/drill-converter/src/components/preview-section.tsx new file mode 100644 index 00000000..1bfdc884 --- /dev/null +++ b/apps/drill-converter/src/components/preview-section.tsx @@ -0,0 +1,253 @@ +import React from "react"; +import { Text, View } from "react-native"; +import type { CoordinateSheetImportResult } from "@eight2five/drill-importers"; +import { formatSetName, type DrillDocument } from "@eight2five/drill-schema"; + +import { PrimaryButton, SectionCard } from "../ui/form-controls"; +import { colors, radius, spacing } from "../ui/theme"; + +export function PreviewSection({ + importResult, + outputDocument, + settingsErrors, + summary, + canDownload, + onDownload, +}: { + readonly importResult?: CoordinateSheetImportResult; + readonly outputDocument?: DrillDocument; + readonly settingsErrors: readonly string[]; + readonly summary?: { + readonly performers: number; + readonly props: number; + readonly primarySets: number; + readonly setEntries: number; + readonly positions: number; + }; + readonly canDownload: boolean; + readonly onDownload: () => void; +}) { + const diagnostics = importResult?.diagnostics ?? []; + const errors = [ + ...settingsErrors, + ...diagnostics + .filter((diagnostic) => diagnostic.severity === "error") + .map((diagnostic) => diagnostic.message), + ]; + const warnings = diagnostics + .filter((diagnostic) => diagnostic.severity === "warning") + .map((diagnostic) => diagnostic.message); + + return ( + + {!importResult ? ( + + Choose a PDF to see parsed performers, sets, coordinates, and + validation results. + + ) : null} + + {errors.length > 0 ? ( + + ) : null} + {warnings.length > 0 ? ( + + ) : null} + + {outputDocument && summary ? ( + + + + + + + + + + + + `${entity.label} · ${entity.type} · symbol ${entity.symbol}`, + )} + remainder={Math.max(0, outputDocument.entities.length - 10)} + /> + { + const measures = set.measureRange + ? set.measureRange.start === set.measureRange.end + ? `m. ${set.measureRange.start}` + : `m. ${set.measureRange.start}–${set.measureRange.end}` + : "measures —"; + return `${formatSetName(set)} · ${ + set.countsFromPrevious + } ct · ${measures}`; + })} + remainder={Math.max(0, outputDocument.sets.length - 10)} + /> + + + + + Valid Eight2Five {outputDocument.schemaVersion} drill document. + The first set has zero incoming counts, set IDs follow array + order, and all entity/set references validate. + + + + ) : null} + + + + + + ); +} + +function Metric({ + label, + value, +}: { + readonly label: string; + readonly value: number; +}) { + return ( + + + {value} + + {label} + + ); +} + +function PreviewList({ + title, + values, + remainder, +}: { + readonly title: string; + readonly values: readonly string[]; + readonly remainder: number; +}) { + return ( + + + {title} + + + {values.map((value) => ( + + {value} + + ))} + {remainder > 0 ? ( + + + {remainder} more + + ) : null} + + + ); +} + +function DiagnosticBox({ + title, + messages, + tone, +}: { + readonly title: string; + readonly messages: readonly string[]; + readonly tone: "error" | "warning"; +}) { + const foreground = tone === "error" ? colors.danger : colors.warning; + const background = tone === "error" ? colors.dangerSoft : colors.warningSoft; + return ( + + + {title} + + {messages.map((message, index) => ( + + • {message} + + ))} + + ); +} diff --git a/apps/drill-converter/src/converter-screen.tsx b/apps/drill-converter/src/converter-screen.tsx new file mode 100644 index 00000000..f6a6d781 --- /dev/null +++ b/apps/drill-converter/src/converter-screen.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { ScrollView, Text, View, useWindowDimensions } from "react-native"; + +import { DetailsSection } from "./components/details-section"; +import { EntitySettingsSection } from "./components/entity-settings-section"; +import { FileSection } from "./components/file-section"; +import { PreviewSection } from "./components/preview-section"; +import { useConverterController } from "./converter/use-converter-controller"; +import { colors, spacing } from "./ui/theme"; + +export function ConverterScreen() { + const controller = useConverterController(); + const { width } = useWindowDimensions(); + const contentWidth = Math.min( + 1040, + Math.max(0, width - (width < 720 ? 28 : 64)), + ); + + return ( + + + + + Coordinate Sheet Converter + + + Convert Pyware-style coordinate-sheet PDFs into the portable + Eight2Five drill JSON schema. Sets, counts, measures, performers, + and marching-grid positions are extracted locally in your browser. + + + + void controller.pickPdf()} + onClear={controller.clearPdf} + /> + + + + + + + + + + Eight2Five drill schema v1.0.0 · No account, backend, database, + analytics, or PDF upload. + + + v1 expects PDFs with extractable text. OCR and image-only coordinate + sheets are intentionally out of scope. + + + + + ); +} diff --git a/apps/drill-converter/src/converter/__tests__/settings.test.ts b/apps/drill-converter/src/converter/__tests__/settings.test.ts new file mode 100644 index 00000000..de05a8db --- /dev/null +++ b/apps/drill-converter/src/converter/__tests__/settings.test.ts @@ -0,0 +1,135 @@ +import { + COLOR_PRESETS, + parseDrillDocument, + resolveDrillEntity, + type DrillDocument, +} from "@eight2five/drill-schema"; + +import { + applyConverterSettings, + createDefaultConverterSettings, + createEmptyRuleDraft, + downloadFileName, + inferTitleFromFileName, + validateConverterSettings, +} from "../settings"; + +const source: DrillDocument = parseDrillDocument({ + schema: "https://eight2five.app/schema/drill", + schemaVersion: "1.0.0", + metadata: { + title: "Imported", + createdAt: "2026-08-02T18:00:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entities: [ + { id: 1, type: "performer", symbol: "B", label: "B1" }, + { id: 2, type: "performer", symbol: "X", label: "P2" }, + ], + sets: [ + { id: 0, number: 1, kind: "set", countsFromPrevious: 0 }, + { id: 1, number: 2, kind: "set", countsFromPrevious: 8 }, + ], + positions: [ + { entityId: 1, setId: 0, xSteps: 0, ySteps: 0 }, + { entityId: 1, setId: 1, xSteps: 8, ySteps: 8 }, + { entityId: 2, setId: 0, xSteps: 0, ySteps: 4 }, + { entityId: 2, setId: 1, xSteps: 4, ySteps: 8 }, + ], + provenance: { + source: { kind: "coordinate-sheet-pdf", fileName: "coords.pdf" }, + references: [ + { + target: { type: "position", entityId: 1, setId: 0 }, + page: 1, + rawText: "Set 1 row", + }, + ], + }, +}); + +describe("drill converter settings", () => { + test("keeps the default UI minimal and validates NFHS settings", () => { + const settings = { ...createDefaultConverterSettings(), title: "Part 4" }; + expect(validateConverterSettings(settings)).toMatchObject({ + field: { type: "preset", preset: "football-nfhs" }, + errors: [], + }); + }); + + test("applies metadata, props, rules, and optional explicit straight paths", () => { + const symbolRule = { + ...createEmptyRuleDraft("rule-1"), + key: "B", + instrument: "Baritone", + color: COLOR_PRESETS.blue, + }; + const labelRule = { + ...createEmptyRuleDraft("rule-2"), + target: "label" as const, + key: "B1", + color: COLOR_PRESETS.green, + labelVisibility: "hidden" as const, + }; + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + drillWriter: "Writer", + ensemble: "UHS", + description: "Final movement", + lucideIcon: "music-2", + propSymbols: ["X"], + rules: [symbolRule, labelRule], + includeSourceReferences: false, + explicitStraightPaths: true, + }; + const validation = validateConverterSettings(settings); + expect(validation.errors).toEqual([]); + + const result = applyConverterSettings(source, settings, validation); + expect(result.metadata).toMatchObject({ + title: "Part 4", + drillWriter: "Writer", + ensemble: "UHS", + description: "Final movement", + lucideIcon: "music-2", + }); + expect(result.entities[1].type).toBe("prop"); + expect(result.provenance?.references).toBeUndefined(); + expect(result.paths).toHaveLength(2); + expect( + resolveDrillEntity(result.entities[0], result.entityRules), + ).toMatchObject({ + instrument: "Baritone", + appearance: { color: COLOR_PRESETS.green, labelVisible: false }, + }); + }); + + test("rejects malformed custom fields and duplicate rule targets", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + fieldMode: "custom" as const, + customFieldJson: "{bad json", + rules: [ + { ...createEmptyRuleDraft("1"), key: "B", instrument: "Baritone" }, + { ...createEmptyRuleDraft("2"), key: "B", instrument: "Trombone" }, + ], + }; + expect(validateConverterSettings(settings).errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("Custom field JSON is invalid"), + expect.stringContaining("Duplicate symbol rule"), + ]), + ); + }); + + test("creates stable human-readable output names", () => { + expect(inferTitleFromFileName("2026_UHS-Part-4_COORDINATES.pdf")).toBe( + "2026 UHS Part 4 COORDINATES", + ); + expect(downloadFileName("Part 4 / Finale!")).toBe( + "part-4-finale.eight2five.json", + ); + }); +}); diff --git a/apps/drill-converter/src/converter/download.web.ts b/apps/drill-converter/src/converter/download.web.ts new file mode 100644 index 00000000..25837b7c --- /dev/null +++ b/apps/drill-converter/src/converter/download.web.ts @@ -0,0 +1,23 @@ +export function downloadTextFile( + contents: string, + fileName: string, + mimeType = "application/json;charset=utf-8", +): void { + if (typeof window === "undefined") { + throw new Error("Downloads are available only in the browser."); + } + const blob = new Blob([contents], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = window.document.createElement("a"); + try { + anchor.href = url; + anchor.download = fileName; + anchor.rel = "noopener"; + anchor.style.display = "none"; + window.document.body.appendChild(anchor); + anchor.click(); + } finally { + anchor.remove(); + URL.revokeObjectURL(url); + } +} diff --git a/apps/drill-converter/src/converter/settings.ts b/apps/drill-converter/src/converter/settings.ts new file mode 100644 index 00000000..57ac435c --- /dev/null +++ b/apps/drill-converter/src/converter/settings.ts @@ -0,0 +1,357 @@ +import { + COLOR_PRESETS, + countPrimarySets, + fieldDefinitionSchema, + getFieldPreset, + parseDrillDocument, + type DrillDocument, + type DrillPath, + type EntityIcon, + type EntityRuleValues, + type EntityRules, + type FieldDefinition, + type FieldPresetId, +} from "@eight2five/drill-schema"; + +export const FIELD_PRESET_OPTIONS = Object.freeze([ + { value: "football-nfhs", label: "High School (NFHS)" }, + { value: "football-ncaa", label: "College (NCAA)" }, + { value: "football-texas-uil", label: "Texas High School (UIL)" }, + { value: "football-nfl", label: "Professional (NFL)" }, +] as const satisfies readonly { + value: FieldPresetId; + label: string; +}[]); + +export const ENTITY_ICON_OPTIONS = Object.freeze([ + "dot", + "square", + "triangle", + "diamond", + "star", + "hexagon", + "cross", +] as const satisfies readonly EntityIcon[]); + +export const COLOR_PRESET_OPTIONS = Object.freeze([ + { label: "Grey", value: COLOR_PRESETS.grey }, + { label: "Red", value: COLOR_PRESETS.red }, + { label: "Orange", value: COLOR_PRESETS.orange }, + { label: "Yellow", value: COLOR_PRESETS.yellow }, + { label: "Green", value: COLOR_PRESETS.green }, + { label: "Blue", value: COLOR_PRESETS.blue }, + { label: "Indigo", value: COLOR_PRESETS.indigo }, + { label: "Violet", value: COLOR_PRESETS.violet }, +] as const); + +export type RuleTarget = "symbol" | "label" | "id"; +export type LabelVisibility = "inherit" | "visible" | "hidden"; + +export interface EntityRuleDraft { + readonly id: string; + readonly target: RuleTarget; + readonly key: string; + readonly section: string; + readonly instrument: string; + readonly icon: "" | EntityIcon; + readonly color: string; + readonly labelVisibility: LabelVisibility; +} + +export interface ConverterSettings { + readonly title: string; + readonly drillWriter: string; + readonly ensemble: string; + readonly description: string; + readonly lucideIcon: string; + readonly fieldMode: FieldPresetId | "custom"; + readonly customFieldJson: string; + readonly propSymbols: readonly string[]; + readonly rules: readonly EntityRuleDraft[]; + readonly includeSourceReferences: boolean; + readonly explicitStraightPaths: boolean; +} + +export interface ConverterSettingsValidation { + readonly field?: FieldDefinition; + readonly entityRules?: EntityRules; + readonly errors: readonly string[]; +} + +export function createDefaultConverterSettings(): ConverterSettings { + return { + title: "", + drillWriter: "", + ensemble: "", + description: "", + lucideIcon: "", + fieldMode: "football-nfhs", + customFieldJson: createDefaultCustomFieldJson(), + propSymbols: [], + rules: [], + includeSourceReferences: true, + explicitStraightPaths: false, + }; +} + +export function createDefaultCustomFieldJson(): string { + const preset = getFieldPreset("football-nfhs"); + return JSON.stringify( + { + type: "custom", + name: "Custom Football Field", + physicalGeometry: preset.physicalGeometry, + marchingGrid: preset.marchingGrid, + } satisfies FieldDefinition, + null, + 2, + ); +} + +export function createEmptyRuleDraft(id: string): EntityRuleDraft { + return { + id, + target: "symbol", + key: "", + section: "", + instrument: "", + icon: "", + color: "", + labelVisibility: "inherit", + }; +} + +export function validateConverterSettings( + settings: ConverterSettings, +): ConverterSettingsValidation { + const errors: string[] = []; + const title = settings.title.trim(); + if (!title) errors.push("A drill title is required."); + + let field: FieldDefinition | undefined; + if (settings.fieldMode === "custom") { + try { + const parsedJson = JSON.parse(settings.customFieldJson) as unknown; + const parsed = fieldDefinitionSchema.safeParse(parsedJson); + if (!parsed.success || parsed.data.type !== "custom") { + errors.push( + parsed.success + ? 'Custom field JSON must have type "custom".' + : `Custom field is invalid: ${ + parsed.error.issues[0]?.message ?? "unknown validation error" + }`, + ); + } else { + field = parsed.data; + } + } catch (cause) { + errors.push( + cause instanceof Error + ? `Custom field JSON is invalid: ${cause.message}` + : "Custom field JSON is invalid.", + ); + } + } else { + field = { type: "preset", preset: settings.fieldMode }; + } + + const entityRules = buildEntityRules(settings.rules, errors); + for (const symbol of settings.propSymbols) { + if (!symbol.trim()) errors.push("Prop symbols cannot be blank."); + } + + return { + ...(field ? { field } : {}), + ...(entityRules ? { entityRules } : {}), + errors, + }; +} + +export function applyConverterSettings( + source: DrillDocument, + settings: ConverterSettings, + validation = validateConverterSettings(settings), +): DrillDocument { + if (validation.errors.length > 0 || !validation.field) { + throw new Error(validation.errors[0] ?? "Converter settings are invalid."); + } + + const propSymbols = new Set(settings.propSymbols); + const entities = source.entities.map((entity) => ({ + ...entity, + type: propSymbols.has(entity.symbol) ? ("prop" as const) : entity.type, + })); + const paths = settings.explicitStraightPaths + ? createStraightPaths(source) + : source.paths; + const provenance = source.provenance + ? { + ...source.provenance, + ...(settings.includeSourceReferences ? {} : { references: undefined }), + } + : undefined; + + return parseDrillDocument({ + ...source, + metadata: { + title: settings.title.trim(), + createdAt: source.metadata.createdAt, + ...optionalText("drillWriter", settings.drillWriter), + ...optionalText("ensemble", settings.ensemble), + ...(settings.description.trim() + ? { description: settings.description.trim() } + : {}), + ...optionalText("lucideIcon", settings.lucideIcon), + }, + field: validation.field, + ...(validation.entityRules && hasRules(validation.entityRules) + ? { entityRules: validation.entityRules } + : { entityRules: undefined }), + entities, + ...(paths && paths.length > 0 ? { paths } : { paths: undefined }), + ...(provenance ? { provenance } : { provenance: undefined }), + }); +} + +export function getDocumentSummary(document: DrillDocument) { + return { + performers: document.entities.filter( + (entity) => entity.type === "performer", + ).length, + props: document.entities.filter((entity) => entity.type === "prop").length, + primarySets: countPrimarySets(document.sets), + setEntries: document.sets.length, + positions: document.positions.length, + }; +} + +export function inferTitleFromFileName(fileName: string): string { + return fileName + .replace(/\.pdf$/i, "") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function downloadFileName(title: string): string { + const slug = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${slug || "drill"}.eight2five.json`; +} + +function buildEntityRules( + drafts: readonly EntityRuleDraft[], + errors: string[], +): EntityRules | undefined { + const bySymbol: Record = {}; + const byLabel: Record = {}; + const byId: Record = {}; + const seen = new Set(); + + for (const [index, draft] of drafts.entries()) { + const key = draft.key.trim(); + if (!key) { + errors.push(`Rule ${index + 1} needs a ${draft.target} key.`); + continue; + } + if (draft.target === "id") { + const parsed = Number(key); + if (!/^(?:0|[1-9][0-9]*)$/.test(key) || !Number.isSafeInteger(parsed)) { + errors.push( + `Rule ${index + 1} ID must be a non-negative safe integer.`, + ); + continue; + } + } + if (draft.target === "symbol" && (key.length > 16 || !key.trim())) { + errors.push(`Rule ${index + 1} symbol must be 1-16 characters.`); + continue; + } + const identity = `${draft.target}:${key}`; + if (seen.has(identity)) { + errors.push(`Duplicate ${draft.target} rule for ${key}.`); + continue; + } + seen.add(identity); + + const color = draft.color.trim(); + if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) { + errors.push(`Rule ${index + 1} color must be a six-digit hex value.`); + continue; + } + + const appearance = { + ...(draft.icon ? { icon: draft.icon } : {}), + ...(color ? { color } : {}), + ...(draft.labelVisibility === "inherit" + ? {} + : { labelVisible: draft.labelVisibility === "visible" }), + }; + const values: EntityRuleValues = { + ...optionalText("section", draft.section), + ...optionalText("instrument", draft.instrument), + ...(Object.keys(appearance).length > 0 ? { appearance } : {}), + }; + if (Object.keys(values).length === 0) continue; + if (draft.target === "symbol") bySymbol[key] = values; + else if (draft.target === "label") byLabel[key] = values; + else byId[key] = values; + } + + const rules: EntityRules = { + ...(Object.keys(bySymbol).length > 0 ? { bySymbol } : {}), + ...(Object.keys(byLabel).length > 0 ? { byLabel } : {}), + ...(Object.keys(byId).length > 0 ? { byId } : {}), + }; + return hasRules(rules) ? rules : undefined; +} + +function createStraightPaths(document: DrillDocument): DrillPath[] { + const positionKeys = new Set( + document.positions.map( + (position) => `${position.entityId}|${position.setId}`, + ), + ); + const paths: DrillPath[] = []; + for (const entity of document.entities) { + for ( + let fromSetId = 0; + fromSetId < document.sets.length - 1; + fromSetId += 1 + ) { + const toSetId = fromSetId + 1; + if ( + positionKeys.has(`${entity.id}|${fromSetId}`) && + positionKeys.has(`${entity.id}|${toSetId}`) + ) { + paths.push({ + entityId: entity.id, + fromSetId, + toSetId, + kind: "straight", + }); + } + } + } + return paths; +} + +function optionalText( + key: Key, + value: string, +): Record | Record { + const trimmed = value.trim(); + return trimmed ? ({ [key]: trimmed } as Record) : {}; +} + +function hasRules(rules: EntityRules): boolean { + return Boolean( + Object.keys(rules.bySymbol ?? {}).length || + Object.keys(rules.byLabel ?? {}).length || + Object.keys(rules.byId ?? {}).length, + ); +} diff --git a/apps/drill-converter/src/converter/use-converter-controller.ts b/apps/drill-converter/src/converter/use-converter-controller.ts new file mode 100644 index 00000000..980d3a23 --- /dev/null +++ b/apps/drill-converter/src/converter/use-converter-controller.ts @@ -0,0 +1,234 @@ +import React from "react"; +import * as DocumentPicker from "expo-document-picker"; +import { + importCoordinateSheetPages, + type CoordinateSheetImportResult, + type ExtractedPdfPage, +} from "@eight2five/drill-importers"; +import { + serializeDrillDocument, + type DrillDocument, +} from "@eight2five/drill-schema"; + +import { downloadTextFile } from "./download.web"; +import { + applyConverterSettings, + createDefaultConverterSettings, + createEmptyRuleDraft, + downloadFileName, + getDocumentSummary, + inferTitleFromFileName, + validateConverterSettings, + type ConverterSettings, + type EntityRuleDraft, +} from "./settings"; +import { extractPdfText, getPdfJsVersion } from "../pdf/pdf-text-extractor.web"; + +export type ConverterPhase = "idle" | "extracting" | "ready" | "error"; + +let nextRuleId = 1; + +export function useConverterController() { + const [settings, setSettings] = React.useState( + createDefaultConverterSettings, + ); + const [asset, setAsset] = + React.useState(); + const [extractedPages, setExtractedPages] = React.useState< + readonly ExtractedPdfPage[] + >([]); + const [createdAt, setCreatedAt] = React.useState(() => + new Date().toISOString(), + ); + const [phase, setPhase] = React.useState("idle"); + const [extractionError, setExtractionError] = React.useState(); + + const settingsValidation = React.useMemo( + () => validateConverterSettings(settings), + [settings], + ); + + const importResult = React.useMemo< + CoordinateSheetImportResult | undefined + >(() => { + if (extractedPages.length === 0 || !settingsValidation.field) + return undefined; + return importCoordinateSheetPages(extractedPages, { + title: settings.title.trim() || "Imported Drill", + ...(asset?.name ? { fileName: asset.name } : {}), + createdAt, + field: settingsValidation.field, + }); + }, [ + asset, + createdAt, + extractedPages, + settings.title, + settingsValidation.field, + ]); + + const outputDocument = React.useMemo(() => { + if (!importResult?.document || settingsValidation.errors.length > 0) { + return undefined; + } + try { + return applyConverterSettings( + importResult.document, + settings, + settingsValidation, + ); + } catch { + return undefined; + } + }, [importResult, settings, settingsValidation]); + + const summary = React.useMemo( + () => (outputDocument ? getDocumentSummary(outputDocument) : undefined), + [outputDocument], + ); + + const availableSymbols = React.useMemo( + () => + Array.from( + new Set( + (importResult?.sheets ?? []) + .map((sheet) => sheet.sourceSymbol.trim()) + .filter(Boolean), + ), + ).sort((left, right) => left.localeCompare(right)), + [importResult?.sheets], + ); + + const pickPdf = React.useCallback(async () => { + const result = await DocumentPicker.getDocumentAsync({ + type: "application/pdf", + multiple: false, + copyToCacheDirectory: false, + }); + if (result.canceled || !result.assets[0]) return; + + const nextAsset = result.assets[0]; + setPhase("extracting"); + setExtractionError(undefined); + setAsset(nextAsset); + setExtractedPages([]); + const nextCreatedAt = new Date().toISOString(); + setCreatedAt(nextCreatedAt); + setSettings((current) => ({ + ...current, + title: inferTitleFromFileName(nextAsset.name), + propSymbols: [], + rules: [], + })); + + try { + const pages = await extractPdfText(nextAsset); + if ( + pages.length === 0 || + pages.every((page) => page.items.length === 0) + ) { + throw new Error( + "The PDF contains no extractable text. Scanned/image-only coordinate sheets are not supported in v1.", + ); + } + setExtractedPages(pages); + setPhase("ready"); + } catch (cause) { + setPhase("error"); + setExtractionError( + cause instanceof Error ? cause.message : "PDF text extraction failed.", + ); + } + }, []); + + const clearPdf = React.useCallback(() => { + setAsset(undefined); + setExtractedPages([]); + setExtractionError(undefined); + setPhase("idle"); + setSettings((current) => ({ + ...current, + title: "", + propSymbols: [], + rules: [], + })); + }, []); + + const updateSettings = React.useCallback( + (patch: Partial) => + setSettings((current) => ({ ...current, ...patch })), + [], + ); + + const addRule = React.useCallback( + (target: EntityRuleDraft["target"] = "symbol") => { + setSettings((current) => ({ + ...current, + rules: [ + ...current.rules, + { ...createEmptyRuleDraft(`rule-${nextRuleId++}`), target }, + ], + })); + }, + [], + ); + + const updateRule = React.useCallback( + (id: string, patch: Partial>) => { + setSettings((current) => ({ + ...current, + rules: current.rules.map((rule) => + rule.id === id ? { ...rule, ...patch } : rule, + ), + })); + }, + [], + ); + + const removeRule = React.useCallback((id: string) => { + setSettings((current) => ({ + ...current, + rules: current.rules.filter((rule) => rule.id !== id), + })); + }, []); + + const togglePropSymbol = React.useCallback((symbol: string) => { + setSettings((current) => ({ + ...current, + propSymbols: current.propSymbols.includes(symbol) + ? current.propSymbols.filter((candidate) => candidate !== symbol) + : [...current.propSymbols, symbol], + })); + }, []); + + const download = React.useCallback(() => { + if (!outputDocument) return; + downloadTextFile( + serializeDrillDocument(outputDocument), + downloadFileName(outputDocument.metadata.title), + ); + }, [outputDocument]); + + return { + phase, + asset, + extractedPages, + extractionError, + settings, + settingsErrors: settingsValidation.errors, + importResult, + outputDocument, + summary, + availableSymbols, + pdfJsVersion: getPdfJsVersion(), + canDownload: Boolean(outputDocument), + pickPdf, + clearPdf, + updateSettings, + addRule, + updateRule, + removeRule, + togglePropSymbol, + download, + }; +} diff --git a/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts b/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts new file mode 100644 index 00000000..f3bbc026 --- /dev/null +++ b/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts @@ -0,0 +1,134 @@ +import type { DocumentPickerAsset } from "expo-document-picker"; +import type { + ExtractedPdfPage, + ExtractedPdfTextItem, +} from "@eight2five/drill-importers"; + +const PDFJS_VERSION = "6.2.108"; +const PDFJS_MODULE_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.min.mjs`; +const PDFJS_WORKER_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.worker.min.mjs`; + +interface PdfJsTextItem { + readonly str: string; + readonly transform: readonly number[]; + readonly width?: number; + readonly height?: number; +} + +interface PdfJsTextContent { + readonly items: readonly (PdfJsTextItem | Record)[]; +} + +interface PdfJsPage { + readonly view: readonly number[]; + getTextContent(): Promise; +} + +interface PdfJsDocument { + readonly numPages: number; + getPage(pageNumber: number): Promise; + destroy(): Promise; +} + +interface PdfJsModule { + readonly GlobalWorkerOptions: { workerSrc: string }; + getDocument(source: { data: Uint8Array }): { + readonly promise: Promise; + }; +} + +let pdfJsPromise: Promise | undefined; + +/** + * Load PDF.js only in the browser. The pinned library bundle is fetched from + * jsDelivr, while the selected PDF bytes remain local to the user's browser. + */ +async function loadPdfJs(): Promise { + if (typeof window === "undefined") { + throw new Error("PDF extraction is available only in the browser."); + } + if (!pdfJsPromise) { + pdfJsPromise = (async () => { + // Metro cannot statically bundle an https: ESM import. Using the native + // browser importer keeps the converter package dependency-free while + // still pinning PDF.js to an exact version. + const importModule = new Function("url", "return import(url);") as ( + url: string, + ) => Promise; + const pdfjs = await importModule(PDFJS_MODULE_URL); + pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; + return pdfjs; + })(); + } + return await pdfJsPromise; +} + +export async function extractPdfText( + asset: DocumentPickerAsset, +): Promise { + const bytes = new Uint8Array(await readAssetBytes(asset)); + const pdfjs = await loadPdfJs(); + const document = await pdfjs.getDocument({ data: bytes }).promise; + try { + const pages: ExtractedPdfPage[] = []; + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { + const page = await document.getPage(pageNumber); + const textContent = await page.getTextContent(); + const items = textContent.items + .filter(isTextItem) + .map(toExtractedTextItem) + .filter((item) => item.text.trim().length > 0); + pages.push({ + pageNumber, + width: Math.abs((page.view[2] ?? 0) - (page.view[0] ?? 0)), + height: Math.abs((page.view[3] ?? 0) - (page.view[1] ?? 0)), + items, + }); + } + return pages; + } finally { + await document.destroy(); + } +} + +export function getPdfJsVersion(): string { + return PDFJS_VERSION; +} + +async function readAssetBytes( + asset: DocumentPickerAsset, +): Promise { + if (asset.file) return await asset.file.arrayBuffer(); + const response = await fetch(asset.uri); + if (!response.ok) { + throw new Error(`Unable to read ${asset.name}: ${response.statusText}.`); + } + return await response.arrayBuffer(); +} + +function isTextItem( + value: PdfJsTextItem | Record, +): value is PdfJsTextItem { + return ( + typeof (value as { str?: unknown }).str === "string" && + Array.isArray((value as { transform?: unknown }).transform) + ); +} + +function toExtractedTextItem(item: PdfJsTextItem): ExtractedPdfTextItem { + return { + text: item.str, + x: finiteCoordinate(item.transform[4]), + y: finiteCoordinate(item.transform[5]), + ...(typeof item.width === "number" && Number.isFinite(item.width) + ? { width: item.width } + : {}), + ...(typeof item.height === "number" && Number.isFinite(item.height) + ? { height: item.height } + : {}), + }; +} + +function finiteCoordinate(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} diff --git a/apps/drill-converter/src/ui/form-controls.tsx b/apps/drill-converter/src/ui/form-controls.tsx new file mode 100644 index 00000000..3ba5af66 --- /dev/null +++ b/apps/drill-converter/src/ui/form-controls.tsx @@ -0,0 +1,382 @@ +import React from "react"; +import { + Pressable, + Text, + TextInput, + View, + type TextInputProps, +} from "react-native"; + +import { colors, radius, spacing } from "./theme"; + +export function FormField({ + label, + helper, + value, + onChangeText, + multiline = false, + placeholder, + error, + ...props +}: { + readonly label: string; + readonly helper?: string; + readonly value: string; + readonly onChangeText: (value: string) => void; + readonly multiline?: boolean; + readonly placeholder?: string; + readonly error?: string; +} & Omit< + TextInputProps, + "value" | "onChangeText" | "multiline" | "placeholder" +>) { + return ( + + + {label} + + + {helper ? ( + + {helper} + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} + +export function ChoiceChips({ + label, + value, + options, + onChange, +}: { + readonly label?: string; + readonly value: Value; + readonly options: readonly { + readonly value: Value; + readonly label: string; + }[]; + readonly onChange: (value: Value) => void; +}) { + return ( + + {label ? ( + + {label} + + ) : null} + + {options.map((option) => { + const selected = option.value === value; + return ( + onChange(option.value)} + accessibilityRole="radio" + accessibilityState={{ checked: selected }} + style={({ pressed }) => ({ + minHeight: 40, + justifyContent: "center", + borderWidth: 1, + borderColor: selected ? colors.accent : colors.borderStrong, + borderRadius: 999, + paddingHorizontal: 14, + backgroundColor: selected + ? colors.accentSoft + : pressed + ? colors.surfaceMuted + : colors.surface, + })} + > + + {option.label} + + + ); + })} + + + ); +} + +export function ToggleRow({ + title, + description, + value, + onChange, +}: { + readonly title: string; + readonly description?: string; + readonly value: boolean; + readonly onChange: (value: boolean) => void; +}) { + return ( + onChange(!value)} + accessibilityRole="checkbox" + accessibilityState={{ checked: value }} + style={({ pressed }) => ({ + flexDirection: "row", + alignItems: "flex-start", + gap: spacing.md, + paddingVertical: spacing.sm, + opacity: pressed ? 0.78 : 1, + })} + > + + {value ? ( + + ✓ + + ) : null} + + + + {title} + + {description ? ( + + {description} + + ) : null} + + + ); +} + +export function SectionCard({ + title, + description, + children, +}: { + readonly title: string; + readonly description?: string; + readonly children: React.ReactNode; +}) { + return ( + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {children} + + ); +} + +export function Disclosure({ + title, + description, + initiallyOpen = false, + children, +}: { + readonly title: string; + readonly description?: string; + readonly initiallyOpen?: boolean; + readonly children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(initiallyOpen); + return ( + + setOpen((value) => !value)} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + style={({ pressed }) => ({ + padding: spacing.lg, + flexDirection: "row", + alignItems: "center", + gap: spacing.md, + backgroundColor: pressed ? colors.surfaceMuted : colors.surface, + })} + > + + + {title} + + {description ? ( + + {description} + + ) : null} + + + {open ? "−" : "+"} + + + {open ? ( + + {children} + + ) : null} + + ); +} + +export function PrimaryButton({ + label, + onPress, + disabled = false, +}: { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; +}) { + return ( + ({ + minHeight: 44, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + paddingHorizontal: 16, + backgroundColor: disabled + ? "#aab8cf" + : pressed + ? "#315ea9" + : colors.accent, + })} + > + + {label} + + + ); +} + +export function SecondaryButton({ + label, + onPress, + disabled = false, + danger = false, +}: { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly danger?: boolean; +}) { + return ( + ({ + minHeight: 42, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + borderWidth: 1, + borderColor: danger ? "#f1aaa4" : colors.borderStrong, + paddingHorizontal: 14, + backgroundColor: pressed ? colors.surfaceMuted : colors.surface, + opacity: disabled ? 0.5 : 1, + })} + > + + {label} + + + ); +} diff --git a/apps/drill-converter/src/ui/theme.ts b/apps/drill-converter/src/ui/theme.ts new file mode 100644 index 00000000..37436d44 --- /dev/null +++ b/apps/drill-converter/src/ui/theme.ts @@ -0,0 +1,34 @@ +export const colors = Object.freeze({ + page: "#f4f6fa", + surface: "#ffffff", + surfaceMuted: "#f8fafc", + text: "#172033", + textMuted: "#64748b", + border: "#d9e0ea", + borderStrong: "#b8c4d4", + accent: "#3c6ec8", + accentSoft: "#edf3ff", + accentText: "#234b92", + success: "#16794a", + successSoft: "#e9f8ef", + warning: "#9a6200", + warningSoft: "#fff6df", + danger: "#b42318", + dangerSoft: "#fff0ef", + code: "#0f172a", +}); + +export const spacing = Object.freeze({ + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, +}); + +export const radius = Object.freeze({ + sm: 8, + md: 12, + lg: 18, +}); diff --git a/apps/drill-converter/tsconfig.json b/apps/drill-converter/tsconfig.json new file mode 100644 index 00000000..8e2d458a --- /dev/null +++ b/apps/drill-converter/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["react", "jest"] + }, + "include": [ + "app/**/*.ts", + "app/**/*.tsx", + "src/**/*.ts", + "src/**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index 21e6e4eb..71f7afa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,35 @@ "typescript": "~6.0.3" } }, + "apps/drill-converter": { + "name": "eight2five-drill-converter", + "version": "0.0.0", + "dependencies": { + "@eight2five/drill-importers": "0.0.0", + "@eight2five/drill-schema": "0.0.0", + "@expo/metro-runtime": "~57.0.8", + "expo": "~57.0.9", + "expo-document-picker": "~57.0.1", + "expo-router": "~57.0.9", + "react": "19.2.3", + "react-dom": "^19.2.3", + "react-native": "0.86.2", + "react-native-safe-area-context": "~5.7.0", + "react-native-web": "^0.21.2" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/react": "~19.2.10", + "eslint": "^9.0.0", + "eslint-config-expo": "~57.0.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "jest-expo": "~57.0.3", + "prettier": "^3.6.2", + "typescript": "~6.0.3" + } + }, "apps/mobile": { "name": "eight2five-mobile", "version": "0.0.0", diff --git a/package.json b/package.json index c8eec0cc..71f84a70 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "modules/expo-pans-ble-api" ], "scripts": { + "start:drill-converter": "npm run start --workspace apps/drill-converter", + "build:drill-converter": "npm run build:web --workspace apps/drill-converter", "start:mobile": "npm run start --workspace apps/mobile", "start:mobile:mcp": "npm run start:mcp --workspace apps/mobile", "start:testbed": "npm run start --workspace apps/testbed", From eccd0de12aa123eea46f488faa61402082408133 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 15:02:55 -0500 Subject: [PATCH 033/101] fix(drill-schema): Use canonical blue preset --- packages/drill-schema/src/entities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drill-schema/src/entities.ts b/packages/drill-schema/src/entities.ts index b34a7aff..6741eb5c 100644 --- a/packages/drill-schema/src/entities.ts +++ b/packages/drill-schema/src/entities.ts @@ -14,7 +14,7 @@ export const COLOR_PRESETS = Object.freeze({ orange: "#FB8C00", yellow: "#FDD835", green: "#43A047", - blue: "#3C6EC8", + blue: "#3c6ec8", indigo: "#4F51B5", violet: "#8E44AD", grey: DEFAULT_ENTITY_COLOR, From 14fe414426e26815a80587ba99542b279c870ecb Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 16:28:28 -0500 Subject: [PATCH 034/101] fix(ui): Use Eight2Five fonts consistently Apply the loaded Montserrat and Source Sans 3 faces across shared text, form controls, native tab labels, and Skia field numbers so UI no longer falls back to platform fonts. --- apps/mobile/app/(tabs)/_layout.tsx | 3 +- apps/testbed/app/(tabs)/_layout.tsx | 3 +- package-lock.json | 1 + packages/mobile/package.json | 1 + .../src/field/render/field-static-layer.tsx | 60 +++++++++---------- packages/ui/components/accordion/index.tsx | 4 +- packages/ui/components/actionsheet/index.tsx | 8 +-- packages/ui/components/alert/index.tsx | 2 +- packages/ui/components/avatar/index.tsx | 2 +- packages/ui/components/badge/index.tsx | 2 +- packages/ui/components/bottomsheet/index.tsx | 4 +- packages/ui/components/button/index.tsx | 5 +- packages/ui/components/calendar/index.tsx | 8 +-- packages/ui/components/calendar/styles.tsx | 12 ++-- packages/ui/components/chat-ai/ChatInput.tsx | 2 +- .../ui/components/chat-ai/attatchments.tsx | 2 +- .../ui/components/chat-ai/conversation.tsx | 2 +- .../components/chat-ai/conversation.web.tsx | 2 +- packages/ui/components/chat-ai/message.tsx | 2 +- .../ui/components/chat-ai/model-selector.tsx | 4 +- packages/ui/components/checkbox/index.tsx | 2 +- .../ui/components/date-time-picker/index.tsx | 6 +- .../ui/components/date-time-picker/styles.tsx | 2 +- packages/ui/components/fab/index.tsx | 4 +- packages/ui/components/form-control/index.tsx | 12 ++-- packages/ui/components/heading/index.tsx | 5 +- packages/ui/components/heading/styles.tsx | 6 +- packages/ui/components/image-viewer/index.tsx | 8 +-- .../ui/components/image-viewer/index.web.tsx | 6 +- packages/ui/components/input/index.tsx | 2 +- packages/ui/components/link/index.tsx | 4 +- packages/ui/components/menu/index.tsx | 4 +- packages/ui/components/radio/index.tsx | 2 +- packages/ui/components/select/index.tsx | 2 +- .../components/select/select-actionsheet.tsx | 8 +-- packages/ui/components/table/styles.tsx | 6 +- packages/ui/components/tabs/index.tsx | 2 +- packages/ui/components/text/index.tsx | 3 +- packages/ui/components/text/styles.tsx | 2 +- packages/ui/components/textarea/index.tsx | 2 +- packages/ui/components/toast/index.tsx | 8 +-- packages/ui/components/tooltip/index.tsx | 4 +- packages/ui/theme/index.tsx | 7 ++- packages/ui/theme/theme.css | 7 +++ 44 files changed, 126 insertions(+), 117 deletions(-) diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index 7e91c867..5ba86925 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import { NativeTabs } from "expo-router/unstable-native-tabs"; -import { useEight2FiveTheme } from "@eight2five/ui/theme"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; import { MOBILE_TABS } from "../../src/navigation/mobile-tabs"; import { useTabBarVisibility } from "../../src/navigation/tab-bar-visibility-context"; @@ -15,6 +15,7 @@ export default function MobileTabsLayout() { backgroundColor={theme.surface} hidden={nativeTabBarHidden} iconColor={{ default: theme.textMuted, selected: theme.accent }} + labelStyle={{ fontFamily: eight2FiveFonts.utilityMedium }} tintColor={theme.accent} backBehavior="initialRoute" > diff --git a/apps/testbed/app/(tabs)/_layout.tsx b/apps/testbed/app/(tabs)/_layout.tsx index aa79ffc7..ec1657b6 100644 --- a/apps/testbed/app/(tabs)/_layout.tsx +++ b/apps/testbed/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import { NativeTabs } from "expo-router/unstable-native-tabs"; -import { useEight2FiveTheme } from "@eight2five/ui/theme"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; import { MANAGER_TABS } from "../../src/pans-manager/manager-tabs"; @@ -10,6 +10,7 @@ export default function TestbedTabsLayout() { {MANAGER_TABS.map((tab) => ( diff --git a/package-lock.json b/package-lock.json index 71f7afa6..531e4236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19032,6 +19032,7 @@ "version": "0.0.0", "dependencies": { "@eight2five/drill-schema": "0.0.0", + "@expo-google-fonts/montserrat": "^0.4.2", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index e5dd095d..60964e38 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@eight2five/drill-schema": "0.0.0", + "@expo-google-fonts/montserrat": "^0.4.2", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", diff --git a/packages/mobile/src/field/render/field-static-layer.tsx b/packages/mobile/src/field/render/field-static-layer.tsx index a31b0ca0..7cb27a41 100644 --- a/packages/mobile/src/field/render/field-static-layer.tsx +++ b/packages/mobile/src/field/render/field-static-layer.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Group, matchFont, Path, Rect, Text } from "@shopify/react-native-skia"; +import { Montserrat_600SemiBold } from "@expo-google-fonts/montserrat/600SemiBold"; +import { Group, Path, Rect, Text, useFont } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import type { StandardHighSchoolFieldTemplate } from "../template"; @@ -23,14 +24,9 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ const fiveYardStroke = useDerivedValue(() => metersPerPixel.value * 1.1); const fieldLineStroke = useDerivedValue(() => metersPerPixel.value * 1.4); const boundaryStroke = useDerivedValue(() => metersPerPixel.value * 2); - const numberFont = React.useMemo( - () => - matchFont({ - fontFamily: "Montserrat", - fontSize: template.dimensions.yardNumberHeightMeters, - fontWeight: "600", - }), - [template], + const numberFont = useFont( + Montserrat_600SemiBold, + template.dimensions.yardNumberHeightMeters, ); const fieldClip = { x: template.bounds.minXMeters, @@ -77,28 +73,30 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ style="stroke" strokeWidth={boundaryStroke} /> - {template.yardNumbers.map((number) => { - const width = numberFont.measureText(number.label).width; - return ( - - - - ); - })} + {numberFont + ? template.yardNumbers.map((number) => { + const width = numberFont.measureText(number.label).width; + return ( + + + + ); + }) + : null} ); }); diff --git a/packages/ui/components/accordion/index.tsx b/packages/ui/components/accordion/index.tsx index f7ff96d9..c75a7b3e 100644 --- a/packages/ui/components/accordion/index.tsx +++ b/packages/ui/components/accordion/index.tsx @@ -24,7 +24,7 @@ const accordionItemStyle = tva({ }); const accordionTitleTextStyle = tva({ - base: 'text-foreground font-medium flex-1 text-left text-sm', + base: 'text-foreground font-body-medium flex-1 text-left text-sm', }); const accordionIconStyle = tva({ @@ -32,7 +32,7 @@ const accordionIconStyle = tva({ }); const accordionContentTextStyle = tva({ - base: 'text-foreground text-sm font-normal', + base: 'text-foreground text-sm font-body', }); const accordionHeaderStyle = tva({ diff --git a/packages/ui/components/actionsheet/index.tsx b/packages/ui/components/actionsheet/index.tsx index 09bcf942..cf0288aa 100644 --- a/packages/ui/components/actionsheet/index.tsx +++ b/packages/ui/components/actionsheet/index.tsx @@ -73,13 +73,13 @@ const actionsheetItemStyle = tva({ }); const actionsheetItemTextStyle = tva({ - base: 'text-foreground font-normal text-sm', + base: 'text-foreground font-body text-sm', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', @@ -119,13 +119,13 @@ const actionsheetSectionListStyle = tva({ }); const actionsheetSectionHeaderTextStyle = tva({ - base: 'leading-5 font-semibold my-0 text-muted-foreground p-3 uppercase text-xs', + base: 'leading-5 font-body-semibold my-0 text-muted-foreground p-3 uppercase text-xs', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/alert/index.tsx b/packages/ui/components/alert/index.tsx index b2c6e2d4..b9fcdd42 100644 --- a/packages/ui/components/alert/index.tsx +++ b/packages/ui/components/alert/index.tsx @@ -20,7 +20,7 @@ const alertStyle = tva({ }); const alertTextStyle = tva({ - base: 'font-medium tracking-tight text-sm flex-1', + base: 'font-body-medium tracking-tight text-sm flex-1', parentVariants: { variant: { default: 'text-card-foreground', diff --git a/packages/ui/components/avatar/index.tsx b/packages/ui/components/avatar/index.tsx index 08ff16d2..13d23cbb 100644 --- a/packages/ui/components/avatar/index.tsx +++ b/packages/ui/components/avatar/index.tsx @@ -19,7 +19,7 @@ const avatarStyle = tva({ }); const avatarFallbackTextStyle = tva({ - base: 'text-foreground text-xs font-medium text-transform:uppercase', + base: 'text-foreground text-xs font-body-medium text-transform:uppercase', }); const avatarGroupStyle = tva({ diff --git a/packages/ui/components/badge/index.tsx b/packages/ui/components/badge/index.tsx index ba479256..3ed46cb3 100644 --- a/packages/ui/components/badge/index.tsx +++ b/packages/ui/components/badge/index.tsx @@ -23,7 +23,7 @@ const badgeStyle = tva({ }); const badgeTextStyle = tva({ - base: 'text-xs font-medium tracking-normal uppercase', + base: 'text-xs font-body-medium tracking-normal uppercase', parentVariants: { variant: { default: 'text-primary-foreground', diff --git a/packages/ui/components/bottomsheet/index.tsx b/packages/ui/components/bottomsheet/index.tsx index bbd3d962..2cf2efab 100644 --- a/packages/ui/components/bottomsheet/index.tsx +++ b/packages/ui/components/bottomsheet/index.tsx @@ -49,7 +49,7 @@ const bottomSheetItemStyle = tva({ base: 'p-3 flex-row items-center rounded-sm w-full disabled:opacity-40 web:pointer-events-auto disabled:cursor-not-allowed hover:bg-accent/40 active:bg-accent/50 data-[focus=true]:bg-accent/20 web:data-[focus-visible=true]:bg-accent/40', }); const bottomSheetItemTextStyle = tva({ - base: 'text-foreground font-normal text-sm', + base: 'text-foreground font-body text-sm', }); const bottomSheetFooterStyle = tva({ @@ -57,7 +57,7 @@ const bottomSheetFooterStyle = tva({ }); const bottomSheetTextInputStyle = tva({ - base: 'flex-1 text-foreground text-sm md:text-sm py-1 placeholder:text-muted-foreground web:outline-none ios:leading-[0px] web:cursor-text h-9 w-full flex-row items-center rounded-md border border-border dark:bg-input/30 bg-transparent shadow-xs overflow-hidden px-3 gap-2', + base: 'flex-1 text-foreground text-sm md:text-sm font-body py-1 placeholder:text-muted-foreground web:outline-none ios:leading-[0px] web:cursor-text h-9 w-full flex-row items-center rounded-md border border-border dark:bg-input/30 bg-transparent shadow-xs overflow-hidden px-3 gap-2', }); type BottomSheetContextValue = { diff --git a/packages/ui/components/button/index.tsx b/packages/ui/components/button/index.tsx index 5ad32817..04f543f5 100644 --- a/packages/ui/components/button/index.tsx +++ b/packages/ui/components/button/index.tsx @@ -10,7 +10,6 @@ import { import { withUniwind } from 'uniwind'; import React from 'react'; import { ActivityIndicator, Pressable, Text, View } from 'react-native'; -import { eight2FiveFonts } from '../../theme'; const SCOPE = 'BUTTON'; const Root = withStyleContext(Pressable, SCOPE); const StyledUIIcon = withUniwind(UIIcon); @@ -45,7 +44,7 @@ const buttonStyle = tva({ }, }); const buttonTextStyle = tva({ - base: 'web:select-none font-sans', + base: 'web:select-none font-heading-semibold', parentVariants: { variant: { default: 'text-primary-foreground', @@ -150,7 +149,7 @@ const ButtonText = React.forwardRef< ( - {label} + {label} )} > @@ -137,7 +137,7 @@ const CalendarHeaderMonthSelectRoot = React.forwardRef< onPress={() => onValueChange?.(item.value)} > {item.label} @@ -160,7 +160,7 @@ const CalendarHeaderYearSelectRoot = React.forwardRef< offset={4} trigger={({ ...triggerProps }) => ( - {label} + {label} )} > @@ -171,7 +171,7 @@ const CalendarHeaderYearSelectRoot = React.forwardRef< onPress={() => onValueChange?.(item.value)} > {item.label} diff --git a/packages/ui/components/calendar/styles.tsx b/packages/ui/components/calendar/styles.tsx index 5a7a03c5..2a5f72ed 100644 --- a/packages/ui/components/calendar/styles.tsx +++ b/packages/ui/components/calendar/styles.tsx @@ -28,7 +28,7 @@ export const calendarHeaderButtonStyle = tva({ }); export const calendarHeaderTitleStyle = tva({ - base: 'text-foreground text-base font-semibold', + base: 'text-foreground text-base font-heading-semibold', variants: {}, }); @@ -44,7 +44,7 @@ export const calendarWeekDayStyle = tva({ }); export const calendarWeekDayTextStyle = tva({ - base: 'text-muted-foreground text-xs font-medium uppercase', + base: 'text-muted-foreground text-xs font-body-medium uppercase', variants: {}, parentVariants: {}, }); @@ -83,7 +83,7 @@ export const calendarDayStyle = tva({ }); export const calendarDayTextStyle = tva({ - base: 'text-foreground text-sm font-normal z-10', + base: 'text-foreground text-sm font-body z-10', variants: { state: { 'default': 'text-foreground', @@ -91,8 +91,8 @@ export const calendarDayTextStyle = tva({ 'today': 'text-accent-foreground', 'disabled': 'text-muted-foreground', 'outside-month': 'text-muted-foreground', - 'range-start': 'text-primary-foreground font-semibold', - 'range-end': 'text-primary-foreground font-semibold', + 'range-start': 'text-primary-foreground font-body-semibold', + 'range-end': 'text-primary-foreground font-body-semibold', 'range-middle': 'text-foreground', }, }, @@ -122,7 +122,7 @@ export const calendarWeekNumberStyle = tva({ }); export const calendarWeekNumberTextStyle = tva({ - base: 'text-muted-foreground text-xs font-normal', + base: 'text-muted-foreground text-xs font-body', variants: {}, parentVariants: {}, }); diff --git a/packages/ui/components/chat-ai/ChatInput.tsx b/packages/ui/components/chat-ai/ChatInput.tsx index 6fbf3b4a..a2e2b56e 100644 --- a/packages/ui/components/chat-ai/ChatInput.tsx +++ b/packages/ui/components/chat-ai/ChatInput.tsx @@ -24,7 +24,7 @@ const sendButtonStyle = tva({ }); const sendButtonTextStyle = tva({ - base: 'text-primary-foreground font-medium', + base: 'text-primary-foreground font-body-medium', }); interface ChatInputProps extends ViewProps { diff --git a/packages/ui/components/chat-ai/attatchments.tsx b/packages/ui/components/chat-ai/attatchments.tsx index a139b430..dca73002 100644 --- a/packages/ui/components/chat-ai/attatchments.tsx +++ b/packages/ui/components/chat-ai/attatchments.tsx @@ -150,7 +150,7 @@ export const Attachment = ({ className={` group relative ${variant === 'grid' ? 'w-24 h-24 overflow-hidden rounded-lg' : ''} - ${variant === 'inline' ? 'flex h-8 items-center gap-1.5 rounded-md border border-border px-1.5 text-sm font-medium' : ''} + ${variant === 'inline' ? 'flex h-8 items-center gap-1.5 rounded-md border border-border px-1.5 text-sm font-body-medium' : ''} ${variant === 'list' ? 'flex w-full items-center gap-3 rounded-lg border p-3' : ''} ${className} `} diff --git a/packages/ui/components/chat-ai/conversation.tsx b/packages/ui/components/chat-ai/conversation.tsx index 61f55881..fb527be4 100644 --- a/packages/ui/components/chat-ai/conversation.tsx +++ b/packages/ui/components/chat-ai/conversation.tsx @@ -47,7 +47,7 @@ export const ConversationEmptyState = ({ className, }: ConversationEmptyStateProps) => ( - {title} + {title} ); diff --git a/packages/ui/components/chat-ai/conversation.web.tsx b/packages/ui/components/chat-ai/conversation.web.tsx index c42cd5bc..86b2cdc8 100644 --- a/packages/ui/components/chat-ai/conversation.web.tsx +++ b/packages/ui/components/chat-ai/conversation.web.tsx @@ -50,7 +50,7 @@ export const ConversationEmptyState = ({ {icon ?? ( )} - {title} + {title} {description} diff --git a/packages/ui/components/chat-ai/message.tsx b/packages/ui/components/chat-ai/message.tsx index 8ce4eb1b..34dc9386 100644 --- a/packages/ui/components/chat-ai/message.tsx +++ b/packages/ui/components/chat-ai/message.tsx @@ -178,7 +178,7 @@ export const MessageResponse = memo(({ message }: { message: UIMessage }) => { }, strong: (node, children) => ( - + {children} ), diff --git a/packages/ui/components/chat-ai/model-selector.tsx b/packages/ui/components/chat-ai/model-selector.tsx index 552a6cd1..ba3f0ffe 100644 --- a/packages/ui/components/chat-ai/model-selector.tsx +++ b/packages/ui/components/chat-ai/model-selector.tsx @@ -180,7 +180,7 @@ export const ModelSelectorGroup = ({ }: ComponentProps & { heading?: string }) => ( {heading && ( - + {heading} )} @@ -220,7 +220,7 @@ export const ModelSelectorSeparator = ({ export const ModelSelectorLogo = ({ provider }: { provider: string }) => ( - + {provider.slice(0, 2).toUpperCase()} diff --git a/packages/ui/components/checkbox/index.tsx b/packages/ui/components/checkbox/index.tsx index 71cef74e..f2ef4c25 100644 --- a/packages/ui/components/checkbox/index.tsx +++ b/packages/ui/components/checkbox/index.tsx @@ -49,7 +49,7 @@ const checkboxIndicatorStyle = tva({ }); const checkboxLabelStyle = tva({ - base: 'text-foreground text-sm font-medium font-body web:select-none web:cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50', + base: 'text-foreground text-sm font-body-medium web:select-none web:cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50', }); const checkboxIconStyle = tva({ diff --git a/packages/ui/components/date-time-picker/index.tsx b/packages/ui/components/date-time-picker/index.tsx index 5bd5c9c1..f3d1dc6b 100644 --- a/packages/ui/components/date-time-picker/index.tsx +++ b/packages/ui/components/date-time-picker/index.tsx @@ -417,11 +417,11 @@ function IOSDateTimePicker({ - + Cancel - + {mode === 'date' ? 'Select Date' : mode === 'time' @@ -429,7 +429,7 @@ function IOSDateTimePicker({ : 'Select Date & Time'} - Done + Done & React.ComponentPropsWithoutRef & { @@ -212,7 +211,7 @@ const Heading = memo( highlight: highlight as boolean, class: className, })} - style={[{ fontFamily: eight2FiveFonts.styleBold }, style]} + style={style} {...props} /> ); @@ -222,7 +221,7 @@ const Heading = memo( diff --git a/packages/ui/components/heading/styles.tsx b/packages/ui/components/heading/styles.tsx index 3e583127..d18ac4c1 100644 --- a/packages/ui/components/heading/styles.tsx +++ b/packages/ui/components/heading/styles.tsx @@ -1,17 +1,17 @@ import { tva } from '@gluestack-ui/utils/nativewind-utils'; import { isWeb } from '@gluestack-ui/utils/nativewind-utils'; const baseStyle = isWeb - ? 'font-sans tracking-sm bg-transparent border-0 box-border display-inline list-none margin-0 padding-0 position-relative text-start no-underline whitespace-pre-wrap word-wrap-break-word' + ? 'tracking-sm bg-transparent border-0 box-border display-inline list-none margin-0 padding-0 position-relative text-start no-underline whitespace-pre-wrap word-wrap-break-word' : ''; export const headingStyle = tva({ - base: `text-foreground font-bold font-heading tracking-sm my-0 ${baseStyle}`, + base: `text-foreground font-heading-bold tracking-sm my-0 ${baseStyle}`, variants: { isTruncated: { true: 'truncate', }, bold: { - true: 'font-bold', + true: 'font-heading-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/image-viewer/index.tsx b/packages/ui/components/image-viewer/index.tsx index 523f0f84..2c332126 100644 --- a/packages/ui/components/image-viewer/index.tsx +++ b/packages/ui/components/image-viewer/index.tsx @@ -63,7 +63,7 @@ const imageViewerCounterStyle = tva({ }); const imageViewerCounterTextStyle = tva({ - base: 'text-white text-sm font-medium bg-black/60 px-4 py-2 rounded-full', + base: 'text-white text-sm font-body-medium bg-black/60 px-4 py-2 rounded-full', }); interface ImageItem { @@ -671,7 +671,7 @@ const ImageViewerCloseButton = React.forwardRef< accessibilityRole="button" {...props} > - + ); }); @@ -697,7 +697,7 @@ const ImageViewerNavigation = React.forwardRef( accessibilityLabel="Previous image" accessibilityRole="button" > - + )} @@ -708,7 +708,7 @@ const ImageViewerNavigation = React.forwardRef( accessibilityLabel="Next image" accessibilityRole="button" > - + )} diff --git a/packages/ui/components/image-viewer/index.web.tsx b/packages/ui/components/image-viewer/index.web.tsx index 286e61ca..fd906573 100644 --- a/packages/ui/components/image-viewer/index.web.tsx +++ b/packages/ui/components/image-viewer/index.web.tsx @@ -33,7 +33,7 @@ const imageViewerContentStyle = tva({ }); const imageViewerCloseButtonStyle = tva({ - base: 'absolute top-4 right-4 z-50 w-10 h-10 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white text-xl font-bold cursor-pointer backdrop-blur-sm', + base: 'absolute top-4 right-4 z-50 w-10 h-10 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white text-xl font-body-bold cursor-pointer backdrop-blur-sm', }); const imageViewerNavigationStyle = tva({ @@ -41,7 +41,7 @@ const imageViewerNavigationStyle = tva({ }); const imageViewerNavButtonStyle = tva({ - base: 'w-12 h-12 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white text-2xl font-bold cursor-pointer pointer-events-auto backdrop-blur-sm', + base: 'w-12 h-12 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white text-2xl font-body-bold cursor-pointer pointer-events-auto backdrop-blur-sm', }); const imageViewerCounterStyle = tva({ @@ -49,7 +49,7 @@ const imageViewerCounterStyle = tva({ }); const imageViewerCounterTextStyle = tva({ - base: 'text-white text-sm font-medium bg-black/60 px-4 py-2 rounded-full', + base: 'text-white text-sm font-body-medium bg-black/60 px-4 py-2 rounded-full', }); // Context for ImageViewer - with default values to prevent errors when used outside provider diff --git a/packages/ui/components/input/index.tsx b/packages/ui/components/input/index.tsx index 03b0b2fd..29139136 100644 --- a/packages/ui/components/input/index.tsx +++ b/packages/ui/components/input/index.tsx @@ -33,7 +33,7 @@ const inputSlotStyle = tva({ }); const inputFieldStyle = tva({ - base: 'flex-1 text-foreground text-sm md:text-sm py-1 h-full placeholder:text-muted-foreground web:outline-none ios:leading-[0px] web:cursor-text web:data-[disabled=true]:cursor-not-allowed', + base: 'flex-1 text-foreground text-sm md:text-sm font-body py-1 h-full placeholder:text-muted-foreground web:outline-none ios:leading-[0px] web:cursor-text web:data-[disabled=true]:cursor-not-allowed', }); type IInputProps = React.ComponentProps & diff --git a/packages/ui/components/link/index.tsx b/packages/ui/components/link/index.tsx index 27d4a600..f778b8d5 100644 --- a/packages/ui/components/link/index.tsx +++ b/packages/ui/components/link/index.tsx @@ -18,14 +18,14 @@ const linkStyle = tva({ }); const linkTextStyle = tva({ - base: 'underline text-primary data-[hover=true]:text-primary/80 data-[hover=true]:no-underline data-[active=true]:text-destructive/80 font-normal font-body web:font-sans web:tracking-sm web:my-0 web:bg-transparent web:border-0 web:box-border web:inline web:list-none web:m-0 web:p-0 web:relative web:text-start web:whitespace-pre-wrap web:break-words', + base: 'underline text-primary data-[hover=true]:text-primary/80 data-[hover=true]:no-underline data-[active=true]:text-destructive/80 font-body web:font-sans web:tracking-sm web:my-0 web:bg-transparent web:border-0 web:box-border web:inline web:list-none web:m-0 web:p-0 web:relative web:text-start web:whitespace-pre-wrap web:break-words', variants: { isTruncated: { true: 'web:truncate', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/menu/index.tsx b/packages/ui/components/menu/index.tsx index 5b766d3a..0376e0c1 100644 --- a/packages/ui/components/menu/index.tsx +++ b/packages/ui/components/menu/index.tsx @@ -27,14 +27,14 @@ const menuSeparatorStyle = tva({ }); const menuItemLabelStyle = tva({ - base: 'text-popover-foreground font-normal font-body', + base: 'text-popover-foreground font-body', variants: { isTruncated: { true: 'web:truncate', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/radio/index.tsx b/packages/ui/components/radio/index.tsx index d10faa5e..21177e63 100644 --- a/packages/ui/components/radio/index.tsx +++ b/packages/ui/components/radio/index.tsx @@ -62,7 +62,7 @@ const radioIndicatorStyle = tva({ }); const radioLabelStyle = tva({ - base: 'text-foreground text-sm font-medium web:select-none web:cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 font-body', + base: 'text-foreground text-sm font-body-medium web:select-none web:cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50', parentVariants: { size: { '2xs': 'text-2xs', diff --git a/packages/ui/components/select/index.tsx b/packages/ui/components/select/index.tsx index 89355980..5d5219b7 100644 --- a/packages/ui/components/select/index.tsx +++ b/packages/ui/components/select/index.tsx @@ -72,7 +72,7 @@ const selectTriggerStyle = tva({ }); const selectInputStyle = tva({ - base: 'px-3 placeholder:text-foreground/50 web:w-full h-full text-foreground/90 pointer-events-none web:outline-none ios:leading-[0px] py-0', + base: 'px-3 placeholder:text-foreground/50 web:w-full h-full text-foreground/90 font-body pointer-events-none web:outline-none ios:leading-[0px] py-0', parentVariants: { size: { xl: 'text-xl', diff --git a/packages/ui/components/select/select-actionsheet.tsx b/packages/ui/components/select/select-actionsheet.tsx index fe90d4b5..b39da633 100644 --- a/packages/ui/components/select/select-actionsheet.tsx +++ b/packages/ui/components/select/select-actionsheet.tsx @@ -76,13 +76,13 @@ const actionsheetItemStyle = tva({ }); const actionsheetItemTextStyle = tva({ - base: 'text-foreground/70 font-normal font-body tracking-md text-left mx-2', + base: 'text-foreground/70 font-body tracking-md text-left mx-2', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', @@ -138,13 +138,13 @@ const actionsheetSectionListStyle = tva({ }); const actionsheetSectionHeaderTextStyle = tva({ - base: 'leading-5 font-bold font-heading my-0 text-foreground/50 p-3 uppercase', + base: 'leading-5 font-heading-bold my-0 text-foreground/50 p-3 uppercase', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-heading-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/table/styles.tsx b/packages/ui/components/table/styles.tsx index 143c6774..43ce760c 100644 --- a/packages/ui/components/table/styles.tsx +++ b/packages/ui/components/table/styles.tsx @@ -20,7 +20,7 @@ export const tableFooterStyle = tva({ }); export const tableHeadStyle = tva({ - base: 'flex-1 px-6 py-[14px] text-left font-bold text-[16px] leading-[22px] text-foreground/80 font-roboto', + base: 'flex-1 px-6 py-[14px] text-left font-body-bold text-[16px] leading-[22px] text-foreground/80', }); export const tableRowStyleStyle = tva({ @@ -36,9 +36,9 @@ export const tableRowStyleStyle = tva({ }); export const tableDataStyle = tva({ - base: 'flex-1 px-6 py-[14px] text-left text-[16px] font-medium leading-[22px] text-foreground/80 font-roboto', + base: 'flex-1 px-6 py-[14px] text-left text-[16px] font-body-medium leading-[22px] text-foreground/80', }); export const tableCaptionStyle = tva({ - base: `${captionTableStyle} px-6 py-[14px] text-[16px] font-normal leading-[22px] text-foreground/90 bg-background/90 font-roboto`, + base: `${captionTableStyle} px-6 py-[14px] text-[16px] font-body leading-[22px] text-foreground/90 bg-background/90`, }); diff --git a/packages/ui/components/tabs/index.tsx b/packages/ui/components/tabs/index.tsx index 0963e3d0..73659ea9 100644 --- a/packages/ui/components/tabs/index.tsx +++ b/packages/ui/components/tabs/index.tsx @@ -49,7 +49,7 @@ const tabsTriggerStyle = tva({ }); const tabsTriggerTextStyle = tva({ - base: 'text-foreground/70 data-[selected=true]:text-foreground font-medium data-[hover=true]:text-foreground/90 ', + base: 'text-foreground/70 data-[selected=true]:text-foreground font-body-medium data-[hover=true]:text-foreground/90 ', }); diff --git a/packages/ui/components/text/index.tsx b/packages/ui/components/text/index.tsx index 5bda4b4b..241632ec 100644 --- a/packages/ui/components/text/index.tsx +++ b/packages/ui/components/text/index.tsx @@ -3,7 +3,6 @@ import React from 'react'; import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; import { Text as RNText } from 'react-native'; import { textStyle } from './styles'; -import { eight2FiveFonts } from '../../theme'; type ITextProps = React.ComponentProps & VariantProps; @@ -38,7 +37,7 @@ const Text = React.forwardRef, ITextProps>( highlight: highlight as boolean, class: className, })} - style={[{ fontFamily: eight2FiveFonts.utilityRegular }, style]} + style={style} {...props} ref={ref} /> diff --git a/packages/ui/components/text/styles.tsx b/packages/ui/components/text/styles.tsx index 49e143f7..92236b79 100644 --- a/packages/ui/components/text/styles.tsx +++ b/packages/ui/components/text/styles.tsx @@ -13,7 +13,7 @@ export const textStyle = tva({ true: 'web:truncate', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/textarea/index.tsx b/packages/ui/components/textarea/index.tsx index 36d5bd05..792dc729 100644 --- a/packages/ui/components/textarea/index.tsx +++ b/packages/ui/components/textarea/index.tsx @@ -33,7 +33,7 @@ const textareaStyle = tva({ }); const textareaInputStyle = tva({ - base: 'p-2 web:outline-0 web:outline-none flex-1 text-foreground placeholder:text-foreground/60 web:cursor-text web:data-[disabled=true]:cursor-not-allowed', + base: 'p-2 web:outline-0 web:outline-none flex-1 text-foreground font-body placeholder:text-foreground/60 web:cursor-text web:data-[disabled=true]:cursor-not-allowed', parentVariants: { size: { sm: 'text-sm', diff --git a/packages/ui/components/toast/index.tsx b/packages/ui/components/toast/index.tsx index 968afba7..5476f1f1 100644 --- a/packages/ui/components/toast/index.tsx +++ b/packages/ui/components/toast/index.tsx @@ -29,13 +29,13 @@ const toastStyle = tva({ }); const toastTitleStyle = tva({ - base: 'font-medium font-body tracking-md text-left', + base: 'font-body-medium tracking-md text-left', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', @@ -125,13 +125,13 @@ const toastTitleStyle = tva({ }); const toastDescriptionStyle = tva({ - base: 'font-normal font-body tracking-md text-left', + base: 'font-body tracking-md text-left', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/tooltip/index.tsx b/packages/ui/components/tooltip/index.tsx index 854243f8..0363d65f 100644 --- a/packages/ui/components/tooltip/index.tsx +++ b/packages/ui/components/tooltip/index.tsx @@ -36,14 +36,14 @@ const tooltipContentStyle = tva({ }); const tooltipTextStyle = tva({ - base: 'font-normal tracking-normal web:select-none text-xs text-foreground/90', + base: 'font-body tracking-normal web:select-none text-xs text-foreground/90', variants: { isTruncated: { true: 'line-clamp-1 truncate', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/theme/index.tsx b/packages/ui/theme/index.tsx index 91c32855..3ab013bb 100644 --- a/packages/ui/theme/index.tsx +++ b/packages/ui/theme/index.tsx @@ -3,6 +3,7 @@ import { Montserrat_500Medium } from '@expo-google-fonts/montserrat/500Medium'; import { Montserrat_600SemiBold } from '@expo-google-fonts/montserrat/600SemiBold'; import { Montserrat_700Bold } from '@expo-google-fonts/montserrat/700Bold'; import { SourceSans3_400Regular } from '@expo-google-fonts/source-sans-3/400Regular'; +import { SourceSans3_500Medium } from '@expo-google-fonts/source-sans-3/500Medium'; import { SourceSans3_600SemiBold } from '@expo-google-fonts/source-sans-3/600SemiBold'; import { SourceSans3_700Bold } from '@expo-google-fonts/source-sans-3/700Bold'; import { useFonts } from 'expo-font'; @@ -71,13 +72,14 @@ export const eight2FiveSpacing = { } as const; export const eight2FiveFonts = { - style: 'Montserrat', - utility: 'Source Sans 3', + style: 'Montserrat_400Regular', + utility: 'SourceSans3_400Regular', styleRegular: 'Montserrat_400Regular', styleMedium: 'Montserrat_500Medium', styleSemibold: 'Montserrat_600SemiBold', styleBold: 'Montserrat_700Bold', utilityRegular: 'SourceSans3_400Regular', + utilityMedium: 'SourceSans3_500Medium', utilitySemibold: 'SourceSans3_600SemiBold', utilityBold: 'SourceSans3_700Bold', } as const; @@ -145,6 +147,7 @@ export function useEight2FiveFonts(): [boolean, Error | null] { Montserrat_600SemiBold, Montserrat_700Bold, SourceSans3_400Regular, + SourceSans3_500Medium, SourceSans3_600SemiBold, SourceSans3_700Bold, }); diff --git a/packages/ui/theme/theme.css b/packages/ui/theme/theme.css index b95a57f9..23454c35 100644 --- a/packages/ui/theme/theme.css +++ b/packages/ui/theme/theme.css @@ -103,6 +103,13 @@ --color-accent: rgb(var(--accent)); --color-accent-foreground: rgb(var(--accent-foreground)); --font-heading: Montserrat_700Bold; + --font-heading-regular: Montserrat_400Regular; + --font-heading-medium: Montserrat_500Medium; + --font-heading-semibold: Montserrat_600SemiBold; + --font-heading-bold: Montserrat_700Bold; --font-body: SourceSans3_400Regular; + --font-body-medium: SourceSans3_500Medium; + --font-body-semibold: SourceSans3_600SemiBold; + --font-body-bold: SourceSans3_700Bold; --font-sans: SourceSans3_400Regular; } From 103a4c36e3c5593b325274758b243f47e599871a Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 16:30:14 -0500 Subject: [PATCH 035/101] chore(deps): update package-lock.json --- package-lock.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/package-lock.json b/package-lock.json index 531e4236..5f1b4746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,44 @@ "typescript": "~6.0.3" } }, + "apps/drill-converter/node_modules/jest-expo": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-57.0.3.tgz", + "integrity": "sha512-Z3tCNmxZwNppxv0fEkiIiLKcR5OlGSnjAY8yhxM6vluojBAnCIM8kwiNzFlI9B5MELEN6tYKDMVbBtoOPfjuBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.2.1", + "@jest/globals": "^29.2.1", + "babel-jest": "^29.2.1", + "jest-environment-jsdom": "^29.2.1", + "jest-snapshot": "^29.2.1", + "jest-watch-select-projects": "^2.0.0", + "jest-watch-typeahead": "2.2.1", + "json5": "^2.2.3", + "lodash": "^4.17.19", + "react-test-renderer": "19.2.3", + "server-only": "^0.0.1", + "stacktrace-js": "^2.0.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "peerDependencies": { + "@react-native/jest-preset": "^0.86.2", + "expo": "*", + "react-native": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, "apps/mobile": { "name": "eight2five-mobile", "version": "0.0.0", @@ -8675,6 +8713,10 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/eight2five-drill-converter": { + "resolved": "apps/drill-converter", + "link": true + }, "node_modules/eight2five-mobile": { "resolved": "apps/mobile", "link": true From 1f0f9c9e6448b03fc8458dd91ac79a5b68c29a0b Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 16:38:33 -0500 Subject: [PATCH 036/101] fix(drill-converter): Destroy PDF loading task --- .../src/pdf/pdf-text-extractor.web.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts b/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts index f3bbc026..7352bb72 100644 --- a/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts +++ b/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts @@ -27,14 +27,16 @@ interface PdfJsPage { interface PdfJsDocument { readonly numPages: number; getPage(pageNumber: number): Promise; +} + +interface PdfJsLoadingTask { + readonly promise: Promise; destroy(): Promise; } interface PdfJsModule { readonly GlobalWorkerOptions: { workerSrc: string }; - getDocument(source: { data: Uint8Array }): { - readonly promise: Promise; - }; + getDocument(source: { data: Uint8Array }): PdfJsLoadingTask; } let pdfJsPromise: Promise | undefined; @@ -68,7 +70,8 @@ export async function extractPdfText( ): Promise { const bytes = new Uint8Array(await readAssetBytes(asset)); const pdfjs = await loadPdfJs(); - const document = await pdfjs.getDocument({ data: bytes }).promise; + const loadingTask = pdfjs.getDocument({ data: bytes }); + const document = await loadingTask.promise; try { const pages: ExtractedPdfPage[] = []; for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { @@ -87,7 +90,7 @@ export async function extractPdfText( } return pages; } finally { - await document.destroy(); + await loadingTask.destroy(); } } From ee1e7c178286b087bad7da71b95ca533e78e8786 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 16:46:02 -0500 Subject: [PATCH 037/101] fix(drill-importers): Split four-up coordinate sheets --- .../src/__tests__/coordinate-sheet.test.ts | 135 ++++++++++++-- .../drill-importers/src/coordinate-sheet.ts | 176 ++++++++++++++---- 2 files changed, 255 insertions(+), 56 deletions(-) diff --git a/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts b/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts index 1a09c599..7e6987b2 100644 --- a/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts +++ b/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts @@ -14,6 +14,7 @@ function item(text: string, x: number, y: number): ExtractedPdfTextItem { function sheetItems({ offsetX, + offsetY = 0, performer, symbol, label, @@ -21,6 +22,7 @@ function sheetItems({ sideShift = 0, }: { offsetX: number; + offsetY?: number; performer: string; symbol: string; label: string; @@ -28,31 +30,32 @@ function sheetItems({ sideShift?: number; }): ExtractedPdfTextItem[] { const x = (value: number) => offsetX + value; + const y = (value: number) => offsetY + value; return [ item( `Performer: ${performer} Symbol: ${symbol} Label: ${label} ID:${id} Part 4`, x(0), - 760, + y(760), ), - item("Set", x(0), 720), - item("Measure", x(55), 720), - item("Counts", x(115), 720), - item("Side 1-Side 2", x(170), 720), - item("Front-Back", x(300), 720), - item("31", x(0), 700), - item("0", x(55), 700), - item("0", x(115), 700), - item("Side 1: On 45 yd ln", x(170), 700), - item("On Front side line", x(300), 700), - item("32", x(0), 680), - item("126-129", x(55), 680), - item("16", x(115), 680), + item("Set", x(0), y(720)), + item("Measure", x(55), y(720)), + item("Counts", x(115), y(720)), + item("Side 1-Side 2", x(170), y(720)), + item("Front-Back", x(300), y(720)), + item("31", x(0), y(700)), + item("0", x(55), y(700)), + item("0", x(115), y(700)), + item("Side 1: On 45 yd ln", x(170), y(700)), + item("On Front side line", x(300), y(700)), + item("32", x(0), y(680)), + item("126-129", x(55), y(680)), + item("16", x(115), y(680)), item( `Side 2: ${4 + sideShift}.0 steps Inside 45 yd ln`, x(170), - 680, + y(680), ), - item("4.0 steps Behind Front Hash (HS)", x(300), 680), + item("4.0 steps Behind Front Hash (HS)", x(300), y(680)), ]; } @@ -128,6 +131,106 @@ describe("coordinate sheet importer", () => { ]); }); + test("splits four-up physical pages into logical sheets in row-major order", () => { + const fourUpPage: ExtractedPdfPage = { + pageNumber: 1, + width: 800, + height: 800, + items: [ + ...sheetItems({ + offsetX: 20, + performer: "Top Left", + symbol: "B", + label: "1", + id: "101", + }), + ...sheetItems({ + offsetX: 420, + performer: "Top Right", + symbol: "B", + label: "2", + id: "102", + }), + ...sheetItems({ + offsetX: 20, + offsetY: -400, + performer: "Bottom Left", + symbol: "C", + label: "1", + id: "103", + }), + ...sheetItems({ + offsetX: 420, + offsetY: -400, + performer: "Bottom Right", + symbol: "C", + label: "2", + id: "104", + }), + ], + }; + const finalPartialPage: ExtractedPdfPage = { + pageNumber: 2, + width: 800, + height: 800, + items: [ + ...sheetItems({ + offsetX: 20, + performer: "Tenth Trumpet", + symbol: "T", + label: "10", + id: "105", + }), + ...sheetItems({ + offsetX: 420, + performer: "(unnamed)", + symbol: "X", + label: "(unlabeled)", + id: "639161623594264901", + }), + ], + }; + + const result = importCoordinateSheetPages([fourUpPage, finalPartialPage], { + title: "Part 4", + createdAt: "2026-08-02T18:00:00.000Z", + }); + + expect(result.sheets).toHaveLength(6); + expect(result.sheets.map((sheet) => sheet.displayLabel)).toEqual([ + "B1", + "B2", + "C1", + "C2", + "T10", + "X", + ]); + expect(result.sheets.every((sheet) => sheet.rows.length === 2)).toBe(true); + expect(result.diagnostics).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "SET_COUNT_MISMATCH" }), + expect.objectContaining({ code: "SOURCE_IDS_REASSIGNED" }), + ]), + ); + expect(result.document?.entities.map((entity) => entity.id)).toEqual([ + 101, + 102, + 103, + 104, + 105, + 0, + ]); + expect(result.document?.extensions?.["eight2five.coordinateSheet"]).toMatchObject({ + sheets: expect.arrayContaining([ + expect.objectContaining({ + entityId: 0, + sourceId: "639161623594264901", + }), + ]), + }); + expect(result.document).toBeDefined(); + }); + test("rejects global set metadata disagreements instead of silently choosing one", () => { const mismatched: ExtractedPdfPage = { ...TWO_UP_PAGE, diff --git a/packages/drill-importers/src/coordinate-sheet.ts b/packages/drill-importers/src/coordinate-sheet.ts index ab7e82c2..e54849bb 100644 --- a/packages/drill-importers/src/coordinate-sheet.ts +++ b/packages/drill-importers/src/coordinate-sheet.ts @@ -53,6 +53,11 @@ interface SheetSlice { readonly items: readonly ExtractedPdfTextItem[]; } +interface SheetAnchor { + readonly x: number; + readonly y: number; +} + interface ParsedSheetResult { readonly sheet?: ParsedCoordinateSheet; readonly diagnostics: readonly ImportDiagnostic[]; @@ -126,51 +131,103 @@ function splitPageIntoSheets(page: ExtractedPdfPage): readonly SheetSlice[] { const usefulItems = page.items.filter((item) => item.text.trim().length > 0); if (usefulItems.length === 0) return []; - let anchors = distinctSortedX( - usefulItems - .filter((item) => /\bPerformer\s*:/i.test(item.text)) - .map((item) => item.x), - ); - if (anchors.length < 2) { - anchors = distinctSortedX( - usefulItems - .filter((item) => /^\s*Set(?:\s|$)/i.test(item.text)) - .map((item) => item.x), - ); + let sheetAnchors = usefulItems + .filter((item) => /\bPerformer\s*:/i.test(item.text)) + .map((item) => ({ x: item.x, y: item.y } satisfies SheetAnchor)); + if (sheetAnchors.length === 0) { + sheetAnchors = usefulItems + .filter((item) => /^\s*Set(?:\s|$)/i.test(item.text)) + .map((item) => ({ x: item.x, y: item.y } satisfies SheetAnchor)); + } + if (sheetAnchors.length === 0) { + sheetAnchors = [ + { + x: Math.min(...usefulItems.map((item) => item.x)), + y: Math.max(...usefulItems.map((item) => item.y)), + }, + ]; } - if (anchors.length === 0) anchors = [Math.min(...usefulItems.map((item) => item.x))]; - - // Sheet anchors are left-edge origins, not centers. A two-up sheet can use - // most of the horizontal distance before the next origin, so midpoint - // partitioning would incorrectly steal the right-side coordinate columns. - return anchors - .map((anchor, sheetIndex) => { - const minX = sheetIndex === 0 ? Number.NEGATIVE_INFINITY : anchor - 0.5; + + const xAnchors = distinctSortedValues( + sheetAnchors.map((anchor) => anchor.x), + "ascending", + ); + const yAnchors = distinctSortedValues( + sheetAnchors.map((anchor) => anchor.y), + "descending", + ); + + // Pyware can print four logical coordinate sheets on one physical PDF page + // (two columns by two rows). Origins are the top-left edges of each logical + // sheet, so midpoint partitioning is wrong: the coordinate columns can use + // almost the full distance up to the next origin. Partition immediately + // before each next origin instead, in both axes, and only emit grid cells + // that actually contain a detected sheet anchor. PDF.js Y increases upward, + // hence rows are ordered from larger to smaller Y values. + const slices: SheetSlice[] = []; + for (const [rowIndex, yAnchor] of yAnchors.entries()) { + const minY = + rowIndex === yAnchors.length - 1 + ? Number.NEGATIVE_INFINITY + : yAnchors[rowIndex + 1] + 0.5; + const maxY = + rowIndex === 0 ? Number.POSITIVE_INFINITY : yAnchor + 0.5; + + for (const [columnIndex, xAnchor] of xAnchors.entries()) { + if (!hasSheetAnchor(sheetAnchors, xAnchor, yAnchor)) continue; + const minX = + columnIndex === 0 ? Number.NEGATIVE_INFINITY : xAnchor - 0.5; const maxX = - sheetIndex === anchors.length - 1 + columnIndex === xAnchors.length - 1 ? Number.POSITIVE_INFINITY - : anchors[sheetIndex + 1] - 0.5; - return { + : xAnchors[columnIndex + 1] - 0.5; + const items = usefulItems.filter( + (item) => + item.x >= minX && item.x < maxX && item.y >= minY && item.y < maxY, + ); + if (items.length === 0) continue; + slices.push({ pageNumber: page.pageNumber, - sheetIndex, - items: usefulItems.filter((item) => item.x >= minX && item.x < maxX), - } satisfies SheetSlice; - }) - .filter((slice) => slice.items.length > 0); + sheetIndex: slices.length, + items, + }); + } + } + return slices; } -function distinctSortedX(values: readonly number[]): number[] { - const sorted = [...values].sort((left, right) => left - right); +function distinctSortedValues( + values: readonly number[], + direction: "ascending" | "descending", +): number[] { + const sorted = [...values].sort((left, right) => + direction === "ascending" ? left - right : right - left, + ); const distinct: number[] = []; for (const value of sorted) { const previous = distinct.at(-1); - if (previous === undefined || Math.abs(value - previous) > SHEET_ANCHOR_DEDUPLICATION) { + if ( + previous === undefined || + Math.abs(value - previous) > SHEET_ANCHOR_DEDUPLICATION + ) { distinct.push(value); } } return distinct; } +function hasSheetAnchor( + anchors: readonly SheetAnchor[], + x: number, + y: number, +): boolean { + return anchors.some( + (anchor) => + Math.abs(anchor.x - x) <= SHEET_ANCHOR_DEDUPLICATION && + Math.abs(anchor.y - y) <= SHEET_ANCHOR_DEDUPLICATION, + ); +} + function parseCoordinateSheetSlice( slice: SheetSlice, field: FieldDefinition, @@ -304,9 +361,9 @@ function isTableHeaderLine(line: TextLine): boolean { function parseHeaderMetadata(text: string): Omit | undefined { const match = text.match(HEADER_FIELD_PATTERN); if (!match) return undefined; - const performerName = cleanOptionalText(match[1]); + const performerName = normalizeHeaderValue(match[1], "unnamed"); const sourceSymbol = cleanOptionalText(match[2]) ?? "?"; - const sourceLabel = cleanOptionalText(match[3]); + const sourceLabel = normalizeHeaderValue(match[3], "unlabeled"); const sourceId = cleanOptionalText(match[4]); const displayLabel = composeDisplayLabel(sourceSymbol, sourceLabel, performerName); const tailStart = (match.index ?? 0) + match[0].length; @@ -333,6 +390,17 @@ function cleanOptionalText(value: string | undefined): string | undefined { return cleaned ? cleaned : undefined; } +function normalizeHeaderValue( + value: string | undefined, + placeholder: string, +): string | undefined { + const cleaned = cleanOptionalText(value); + if (!cleaned) return undefined; + return new RegExp(`^\\(${placeholder}\\)$`, "i").test(cleaned) + ? undefined + : cleaned; +} + function composeDisplayLabel( symbol: string, label: string | undefined, @@ -746,26 +814,54 @@ function buildDrillDocument( function assignEntityIds( sheets: readonly ParsedCoordinateSheet[], diagnostics: ImportDiagnostic[], -): readonly number[] | undefined { - const sourceIds = sheets.map((sheet) => { +): readonly number[] { + const safeSourceIds = sheets.map((sheet) => { if (!sheet.sourceId || !/^\d+$/.test(sheet.sourceId)) return undefined; const parsed = Number(sheet.sourceId); return Number.isSafeInteger(parsed) ? parsed : undefined; }); - const validSourceIds = sourceIds.every( - (id): id is number => id !== undefined, + const reservedSafeIds = new Set( + safeSourceIds.filter((id): id is number => id !== undefined), ); - if (validSourceIds && new Set(sourceIds).size === sourceIds.length) return sourceIds; + const usedIds = new Set(); + let nextGeneratedId = 0; + let hadMissingSourceId = false; + let hadDuplicateSafeSourceId = false; + + const ids = safeSourceIds.map((sourceId, index) => { + if (sourceId !== undefined && !usedIds.has(sourceId)) { + usedIds.add(sourceId); + return sourceId; + } + if (!sheets[index].sourceId) hadMissingSourceId = true; + if (sourceId !== undefined && usedIds.has(sourceId)) { + hadDuplicateSafeSourceId = true; + } + while ( + reservedSafeIds.has(nextGeneratedId) || + usedIds.has(nextGeneratedId) + ) { + nextGeneratedId += 1; + } + const generatedId = nextGeneratedId; + usedIds.add(generatedId); + nextGeneratedId += 1; + return generatedId; + }); - if (sheets.some((sheet) => sheet.sourceId)) { + // Pyware can emit numeric source IDs larger than JavaScript's safe integer + // range. Those exact strings are retained in coordinate-sheet provenance, + // while the portable document receives a generated safe numeric ID. This is + // normal conversion behavior and does not warrant a warning by itself. + if (hadMissingSourceId || hadDuplicateSafeSourceId) { diagnostics.push({ severity: "warning", code: "SOURCE_IDS_REASSIGNED", message: - "One or more source performer IDs were missing, duplicated, or outside JavaScript's safe integer range; portable IDs were assigned sequentially.", + "One or more source performer IDs were missing or duplicated; generated portable IDs were used where needed. Original source IDs are preserved in coordinate-sheet provenance when available.", }); } - return sheets.map((_, index) => index + 1); + return ids; } function sameSetIdentity(left: ParsedSetIdentity, right: ParsedSetIdentity): boolean { From 81f81e3b220e8e4dd3222715560f9dbfa39a3953 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 16:55:03 -0500 Subject: [PATCH 038/101] style: Apply lint fixes --- apps/drill-converter/.gitignore | 6 + .../components/marching-coordinate-form.tsx | 16 +- .../src/features/drill/page-editor-screen.tsx | 4 +- apps/mobile/src/features/drill/page-form.ts | 37 +++- .../coordinate-panel/drill-coordinate-row.tsx | 5 +- .../mobile/src/drill/SqliteDrillRepository.ts | 170 +++++++++++++----- .../drill/__tests__/sqlite-repository.test.ts | 27 +-- .../src/field/__tests__/marching.test.ts | 4 +- packages/mobile/src/field/anchor-position.ts | 10 +- packages/mobile/src/field/marching.ts | 16 +- .../storage/__tests__/mobileDatabase.test.ts | 12 +- packages/mobile/src/storage/mobileDatabase.ts | 12 +- 12 files changed, 227 insertions(+), 92 deletions(-) create mode 100644 apps/drill-converter/.gitignore diff --git a/apps/drill-converter/.gitignore b/apps/drill-converter/.gitignore new file mode 100644 index 00000000..5873d9ab --- /dev/null +++ b/apps/drill-converter/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx index aadff2c0..2ae6227a 100644 --- a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx +++ b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx @@ -279,7 +279,9 @@ export function MarchingCoordinateForm({ style={{ gap: eight2FiveSpacing.xs, borderRadius: eight2FiveRadii.md, - borderColor: validation.errors.coordinate ? theme.danger : theme.border, + borderColor: validation.errors.coordinate + ? theme.danger + : theme.border, backgroundColor: theme.accentSoft, }} > @@ -408,7 +410,11 @@ function SelectField({ isDisabled={disabled} > - choice.value === value)?.label ?? value} /> + choice.value === value)?.label ?? value + } + /> @@ -418,7 +424,11 @@ function SelectField({ {choices.map((choice) => ( - + ))} diff --git a/apps/mobile/src/features/drill/page-editor-screen.tsx b/apps/mobile/src/features/drill/page-editor-screen.tsx index 68b3f26d..0a78aa95 100644 --- a/apps/mobile/src/features/drill/page-editor-screen.tsx +++ b/apps/mobile/src/features/drill/page-editor-screen.tsx @@ -52,9 +52,7 @@ export function PageEditorScreen({ }} > {controller.loading ? ( - - Loading set… - + Loading set… ) : null} {controller.error ? ( diff --git a/apps/mobile/src/features/drill/page-form.ts b/apps/mobile/src/features/drill/page-form.ts index a0e14d1c..23a46e96 100644 --- a/apps/mobile/src/features/drill/page-form.ts +++ b/apps/mobile/src/features/drill/page-form.ts @@ -141,7 +141,9 @@ function setDraftFromPosition( setKind: details.kind, setSuffix: details.suffix ?? "", countsFromPrevious: String(details.countsFromPrevious), - measureStart: details.measureRange ? String(details.measureRange.start) : "", + measureStart: details.measureRange + ? String(details.measureRange.start) + : "", measureEnd: details.measureRange ? String(details.measureRange.end) : "", side: String(coordinate.side.side) as MarchingCoordinateDraft["side"], yardLine: String(coordinate.side.yardLine), @@ -210,7 +212,8 @@ export function previewCoordinate( draft: MarchingCoordinateDraft, ): CoordinatePreview | undefined { const result = coordinateFromDraft(draft); - if (!result.coordinate || Object.keys(result.errors).length > 0) return undefined; + if (!result.coordinate || Object.keys(result.errors).length > 0) + return undefined; return { side: formatMarchingSide(result.coordinate.side), frontBack: formatMarchingFrontBack(result.coordinate.frontBack), @@ -230,7 +233,10 @@ function parseMeasureRange( if (!endText) errors.measureEnd = message; return undefined; } - const start = parseNonNegativeInteger(startText, "Enter a valid start measure."); + const start = parseNonNegativeInteger( + startText, + "Enter a valid start measure.", + ); const end = parseNonNegativeInteger(endText, "Enter a valid end measure."); if (typeof start === "string") errors.measureStart = start; if (typeof end === "string") errors.measureEnd = end; @@ -248,7 +254,10 @@ function coordinateFromDraft(draft: MarchingCoordinateDraft): { readonly position?: DrillGridPoint; } { const errors: SetFormErrors = {}; - const yardLine = parseNonNegativeNumber(draft.yardLine, "Choose a five-yard line."); + const yardLine = parseNonNegativeNumber( + draft.yardLine, + "Choose a five-yard line.", + ); if (typeof yardLine === "string" || !YARD_LINES.includes(yardLine)) { errors.yardLine = "Choose a five-yard line from 0 through 50."; } @@ -261,7 +270,8 @@ function coordinateFromDraft(draft: MarchingCoordinateDraft): { draft.frontBackOffsetSteps, "Enter a finite, non-negative front-to-back offset.", ); - if (typeof frontBackOffset === "string") errors.frontBackOffsetSteps = frontBackOffset; + if (typeof frontBackOffset === "string") + errors.frontBackOffsetSteps = frontBackOffset; if ( typeof yardLine === "string" || typeof sideOffset === "string" || @@ -285,7 +295,8 @@ function coordinateFromDraft(draft: MarchingCoordinateDraft): { normalizedSide !== "center" && normalizedSideRelation !== "outside" ) { - errors.coordinate = "An offset from the 50-yard line must be outside on Side 1 or Side 2."; + errors.coordinate = + "An offset from the 50-yard line must be outside on Side 1 or Side 2."; } const coordinate: MarchingCoordinate = { @@ -315,19 +326,27 @@ function coordinateFromDraft(draft: MarchingCoordinateDraft): { } } -function parseNonNegativeInteger(value: string, message: string): number | string { +function parseNonNegativeInteger( + value: string, + message: string, +): number | string { if (!value.trim()) return message; const number = Number(value); return Number.isSafeInteger(number) && number >= 0 ? number : message; } -function parseNonNegativeNumber(value: string, message: string): number | string { +function parseNonNegativeNumber( + value: string, + message: string, +): number | string { if (!value.trim()) return message; const number = Number(value); return Number.isFinite(number) && number >= 0 ? number : message; } -function parseSide(value: MarchingCoordinateDraft["side"]): MarchingSideReference | undefined { +function parseSide( + value: MarchingCoordinateDraft["side"], +): MarchingSideReference | undefined { if (value === "1") return 1; if (value === "2") return 2; return value === "center" ? "center" : undefined; diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx index f354d920..8710f579 100644 --- a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx +++ b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx @@ -93,7 +93,10 @@ export function DrillCoordinateRow({ ); return landscape ? ( - + {metadata} { const drillId = assertId(id, "Drill id"); - await this.db.runAsync(`DELETE FROM ${DRILLS_TABLE} WHERE id = ?`, [drillId]); + await this.db.runAsync(`DELETE FROM ${DRILLS_TABLE} WHERE id = ?`, [ + drillId, + ]); } async setActiveDrill(id: string | null): Promise { @@ -249,21 +258,25 @@ export class SqliteDrillRepository implements DrillRepository { async getSet(id: string): Promise { const setId = assertId(id, "Drill set id"); - const row = await this.db.getFirstAsync( - `${SET_SELECT} WHERE id = ?`, - [setId], - ); + const row = await this.db.getFirstAsync(`${SET_SELECT} WHERE id = ?`, [ + setId, + ]); return row ? toSet(row) : undefined; } async createSet(input: CreateDrillSetInput): Promise { const normalized = normalizeCreateSet(input); - const createdId = assertId(normalized.id ?? this.idFactory(), "Drill set id"); + const createdId = assertId( + normalized.id ?? this.idFactory(), + "Drill set id", + ); await this.db.withTransactionAsync(async () => { await this.requireDrill(normalized.drillId); const count = await this.setCount(normalized.drillId); if (count === 0 && normalized.countsFromPrevious !== 0) { - throw invalidInput("The first set must have zero counts from previous."); + throw invalidInput( + "The first set must have zero counts from previous.", + ); } await this.insertSetRow({ ...normalized, id: createdId, ordinal: count }); await this.validateSetStructure(normalized.drillId); @@ -271,7 +284,10 @@ export class SqliteDrillRepository implements DrillRepository { return requireValue(await this.getSet(createdId), "drill set", createdId); } - async updateSet(idValue: string, changes: UpdateDrillSetInput): Promise { + async updateSet( + idValue: string, + changes: UpdateDrillSetInput, + ): Promise { const id = assertId(idValue, "Drill set id"); const current = await this.getSet(id); if (!current) throw setNotFound(id); @@ -293,7 +309,9 @@ export class SqliteDrillRepository implements DrillRepository { await this.db.withTransactionAsync(async () => { const set = await this.getSet(id); if (!set) return; - await this.db.runAsync(`DELETE FROM ${DRILL_SETS_TABLE} WHERE id = ?`, [id]); + await this.db.runAsync(`DELETE FROM ${DRILL_SETS_TABLE} WHERE id = ?`, [ + id, + ]); await this.db.runAsync( `UPDATE ${DRILL_SETS_TABLE} SET ordinal = ordinal - 1 @@ -328,12 +346,17 @@ export class SqliteDrillRepository implements DrillRepository { await this.requireDrill(normalized.drillId); const count = await this.setCount(normalized.drillId); if (ordinal > count) { - throw invalidInput(`Set ordinal must be between 0 and ${count} when inserting.`); + throw invalidInput( + `Set ordinal must be between 0 and ${count} when inserting.`, + ); } if (ordinal === 0 && normalized.countsFromPrevious !== 0) { - throw invalidInput("The first set must have zero counts from previous."); + throw invalidInput( + "The first set must have zero counts from previous.", + ); } - if (count > 0) await this.shiftSetsForInsertion(normalized.drillId, count, ordinal); + if (count > 0) + await this.shiftSetsForInsertion(normalized.drillId, count, ordinal); await this.insertSetRow({ ...normalized, id, ordinal }); await this.validateSetStructure(normalized.drillId); }); @@ -437,11 +460,21 @@ export class SqliteDrillRepository implements DrillRepository { } // Compatibility aliases. - listPages(drillId: string) { return this.listSets(drillId); } - getPage(id: string) { return this.getSet(id); } - createPage(input: CreateDrillSetInput) { return this.createSet(input); } - updatePage(id: string, input: UpdateDrillSetInput) { return this.updateSet(id, input); } - deletePage(id: string) { return this.deleteSet(id); } + listPages(drillId: string) { + return this.listSets(drillId); + } + getPage(id: string) { + return this.getSet(id); + } + createPage(input: CreateDrillSetInput) { + return this.createSet(input); + } + updatePage(id: string, input: UpdateDrillSetInput) { + return this.updateSet(id, input); + } + deletePage(id: string) { + return this.deleteSet(id); + } insertPage(drillId: string, ordinal: number, details: CreateDrillSetDetails) { return this.insertSet(drillId, ordinal, details); } @@ -451,7 +484,9 @@ export class SqliteDrillRepository implements DrillRepository { ) { return this.reorderSets(drillId, orderedSetIds); } - setSelectedDrillPage(id: string | null) { return this.setSelectedDrillSet(id); } + setSelectedDrillPage(id: string | null) { + return this.setSelectedDrillSet(id); + } private async requireDrill(id: string): Promise { const drill = await this.getDrill(id); @@ -460,7 +495,9 @@ export class SqliteDrillRepository implements DrillRepository { } private async setCount(drillId: string): Promise { - const row = await this.db.getFirstAsync<{ set_count: SqlValue | undefined }>( + const row = await this.db.getFirstAsync<{ + set_count: SqlValue | undefined; + }>( `SELECT COUNT(*) AS set_count FROM ${DRILL_SETS_TABLE} WHERE drill_id = ?`, [drillId], ); @@ -471,7 +508,9 @@ export class SqliteDrillRepository implements DrillRepository { return count; } - private async insertSetRow(set: NormalizedCreateSet & { id: string; ordinal: number }) { + private async insertSetRow( + set: NormalizedCreateSet & { id: string; ordinal: number }, + ) { const physical = drillGridPointToFieldPoint(set.position); const label = formatSetName(set); await this.db.runAsync( @@ -553,14 +592,20 @@ export class SqliteDrillRepository implements DrillRepository { const identities = new Set(); for (const [index, set] of sets.entries()) { if (set.ordinal !== index) { - throw invalidInput("Persisted set ordinals must be contiguous from zero."); + throw invalidInput( + "Persisted set ordinals must be contiguous from zero.", + ); } if (index === 0 && set.countsFromPrevious !== 0) { - throw invalidInput("The first set must have zero counts from previous."); + throw invalidInput( + "The first set must have zero counts from previous.", + ); } const identity = `${set.number}|${set.suffix ?? ""}`; if (identities.has(identity)) { - throw invalidInput(`Set ${formatSetName(set)} already exists in this drill.`); + throw invalidInput( + `Set ${formatSetName(set)} already exists in this drill.`, + ); } identities.add(identity); if (set.kind === "set") { @@ -607,7 +652,9 @@ function normalizeCreateSet(input: CreateDrillSetInput): NormalizedCreateSet { const kind = input.kind ?? (input.suffix ? "subset" : "set"); const suffix = normalizeSuffix(input.suffix, kind); return { - ...(input.id === undefined ? {} : { id: assertId(input.id, "Drill set id") }), + ...(input.id === undefined + ? {} + : { id: assertId(input.id, "Drill set id") }), drillId: assertId(input.drillId, "Drill id"), number: assertNonNegativeInteger(input.number, "Set number"), ...(suffix === undefined ? {} : { suffix }), @@ -631,7 +678,10 @@ function normalizeExistingSet( changes: UpdateDrillSetInput, ): DrillSet { const kind = changes.kind ?? current.kind; - const rawSuffix = changes.suffix === undefined ? current.suffix : changes.suffix ?? undefined; + const rawSuffix = + changes.suffix === undefined + ? current.suffix + : (changes.suffix ?? undefined); const suffix = normalizeSuffix(rawSuffix, kind); const measureRange = changes.measureRange === undefined @@ -656,15 +706,27 @@ function normalizeExistingSet( countsFromPrevious: changes.countsFromPrevious === undefined ? current.countsFromPrevious - : assertNonNegativeInteger(changes.countsFromPrevious, "countsFromPrevious"), - ...(measureRange === undefined ? { measureRange: undefined } : { measureRange }), + : assertNonNegativeInteger( + changes.countsFromPrevious, + "countsFromPrevious", + ), + ...(measureRange === undefined + ? { measureRange: undefined } + : { measureRange }), position: - changes.position === undefined ? current.position : assertGridPoint(changes.position), - ...(facingDegrees === undefined ? { facingDegrees: undefined } : { facingDegrees }), + changes.position === undefined + ? current.position + : assertGridPoint(changes.position), + ...(facingDegrees === undefined + ? { facingDegrees: undefined } + : { facingDegrees }), }; } -function normalizeSuffix(value: string | undefined, kind: SetKind): string | undefined { +function normalizeSuffix( + value: string | undefined, + kind: SetKind, +): string | undefined { if (kind === "set") { if (value !== undefined && value.trim().length > 0) { throw invalidInput("Primary sets cannot have a suffix."); @@ -672,14 +734,22 @@ function normalizeSuffix(value: string | undefined, kind: SetKind): string | und return undefined; } if (typeof value !== "string" || !/^(?:[A-Z]|\.[0-9]+)$/.test(value.trim())) { - throw invalidInput("A subset suffix must be one capital letter or a decimal such as .5."); + throw invalidInput( + "A subset suffix must be one capital letter or a decimal such as .5.", + ); } return value.trim(); } function assertGridPoint(position: DrillGridPoint): DrillGridPoint { - if (!position || !Number.isFinite(position.xSteps) || !Number.isFinite(position.ySteps)) { - throw invalidInput("Drill set position must contain finite xSteps and ySteps."); + if ( + !position || + !Number.isFinite(position.xSteps) || + !Number.isFinite(position.ySteps) + ) { + throw invalidInput( + "Drill set position must contain finite xSteps and ySteps.", + ); } return { xSteps: position.xSteps, ySteps: position.ySteps }; } @@ -687,7 +757,8 @@ function assertGridPoint(position: DrillGridPoint): DrillGridPoint { function assertMeasureRange(value: MeasureRange): MeasureRange { const start = assertNonNegativeInteger(value.start, "Measure start"); const end = assertNonNegativeInteger(value.end, "Measure end"); - if (end < start) throw invalidInput("Measure end must be at or after measure start."); + if (end < start) + throw invalidInput("Measure end must be at or after measure start."); return { start, end }; } @@ -725,11 +796,7 @@ function assertTimestamp(value: unknown, name: string): number { } function assertNonNegativeInteger(value: unknown, name: string): number { - if ( - typeof value !== "number" || - !Number.isSafeInteger(value) || - value < 0 - ) { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { throw invalidInput(`${name} must be a non-negative integer.`); } return value; @@ -792,7 +859,8 @@ function toSet(row: Row): DrillSet { } function rowText(value: SqlValue | undefined, name: string): string { - if (typeof value !== "string") throw new MobileRowError(`${name} is not a string.`); + if (typeof value !== "string") + throw new MobileRowError(`${name} is not a string.`); return value; } @@ -809,11 +877,15 @@ function rowNumber(value: SqlValue | undefined, name: string): number { function rowInteger(value: SqlValue | undefined, name: string): number { const number = rowNumber(value, name); - if (!Number.isSafeInteger(number)) throw new MobileRowError(`${name} is not an integer.`); + if (!Number.isSafeInteger(number)) + throw new MobileRowError(`${name} is not an integer.`); return number; } -function rowNullableNumber(value: SqlValue | undefined, name: string): number | null { +function rowNullableNumber( + value: SqlValue | undefined, + name: string, +): number | null { if (value === null || value === undefined) return null; return rowNumber(value, name); } @@ -835,11 +907,17 @@ function invalidInput(message: string): DrillRepositoryError { } function drillNotFound(id: string): DrillRepositoryError { - return new DrillRepositoryError("DRILL_NOT_FOUND", `Drill ${id} was not found.`); + return new DrillRepositoryError( + "DRILL_NOT_FOUND", + `Drill ${id} was not found.`, + ); } function setNotFound(id: string): DrillRepositoryError { - return new DrillRepositoryError("SET_NOT_FOUND", `Drill set ${id} was not found.`); + return new DrillRepositoryError( + "SET_NOT_FOUND", + `Drill set ${id} was not found.`, + ); } function defaultIdFactory(): string { diff --git a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts index 8fef63d7..8f0265f1 100644 --- a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts +++ b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts @@ -97,11 +97,7 @@ describe("SqliteDrillRepository", () => { ["set-b", 2], ]); - await repository.reorderSets(drill.id, [ - "set-a", - "set-inserted", - "set-b", - ]); + await repository.reorderSets(drill.id, ["set-a", "set-inserted", "set-b"]); expect((await repository.listSets(drill.id)).map(({ id }) => id)).toEqual([ "set-a", "set-inserted", @@ -150,11 +146,15 @@ describe("SqliteDrillRepository", () => { }); await repository.setActiveDrill(first.id); - await expect(repository.setSelectedDrillSet(set.id)).resolves.toMatchObject({ - activeDrillId: first.id, - selectedDrillSetId: set.id, - }); - await expect(repository.setSelectedDrillSet("missing")).rejects.toMatchObject({ + await expect(repository.setSelectedDrillSet(set.id)).resolves.toMatchObject( + { + activeDrillId: first.id, + selectedDrillSetId: set.id, + }, + ); + await expect( + repository.setSelectedDrillSet("missing"), + ).rejects.toMatchObject({ code: "INVALID_SELECTION", }); await expect(repository.setActiveDrill("missing")).rejects.toMatchObject({ @@ -353,7 +353,8 @@ class DrillFakeDatabase { return [...this.drills.values()] .sort( (left, right) => - left.created_at - right.created_at || left.id.localeCompare(right.id), + left.created_at - right.created_at || + left.id.localeCompare(right.id), ) .map((row) => ({ ...row })); } @@ -365,7 +366,9 @@ class DrillFakeDatabase { left.ordinal - right.ordinal || left.id.localeCompare(right.id), ); return rows.map((row) => - /^\s*SELECT id\s+FROM drill_pages/m.test(sql) ? { id: row.id } : { ...row }, + /^\s*SELECT id\s+FROM drill_pages/m.test(sql) + ? { id: row.id } + : { ...row }, ); } return []; diff --git a/packages/mobile/src/field/__tests__/marching.test.ts b/packages/mobile/src/field/__tests__/marching.test.ts index 4f9e8893..94a6ecf1 100644 --- a/packages/mobile/src/field/__tests__/marching.test.ts +++ b/packages/mobile/src/field/__tests__/marching.test.ts @@ -19,9 +19,7 @@ function gridPoint(xSteps: number, ySteps: number) { describe("marching coordinate conversion", () => { test("formats exact side examples in the centered field convention", () => { expect( - formatMarchingSide( - fieldPointToMarchingCoordinate(gridPoint(16, 0)).side, - ), + formatMarchingSide(fieldPointToMarchingCoordinate(gridPoint(16, 0)).side), ).toBe("Side 2: On 40 yd ln"); expect( formatMarchingSide( diff --git a/packages/mobile/src/field/anchor-position.ts b/packages/mobile/src/field/anchor-position.ts index 5c30ab1d..837d113e 100644 --- a/packages/mobile/src/field/anchor-position.ts +++ b/packages/mobile/src/field/anchor-position.ts @@ -101,8 +101,14 @@ export const ANCHOR_POSITION_REFERENCE_POINTS: Readonly< "side-2-back-corner": point(bounds.maxXMeters, bounds.maxYMeters), "side-1-goal-line-center": point(bounds.minXMeters, centerYMeters), "side-2-goal-line-center": point(bounds.maxXMeters, centerYMeters), - "front-hash-center": point(centerXMeters, template.frontHashLine.coordinateMeters), - "back-hash-center": point(centerXMeters, template.backHashLine.coordinateMeters), + "front-hash-center": point( + centerXMeters, + template.frontHashLine.coordinateMeters, + ), + "back-hash-center": point( + centerXMeters, + template.backHashLine.coordinateMeters, + ), }); export function getAnchorPositionReferencePoint( diff --git a/packages/mobile/src/field/marching.ts b/packages/mobile/src/field/marching.ts index 1a2e6fd1..6d1918cd 100644 --- a/packages/mobile/src/field/marching.ts +++ b/packages/mobile/src/field/marching.ts @@ -259,7 +259,9 @@ function sideCoordinateToXSteps(coordinate: MarchingSideCoordinate): number { if (coordinate.yardLine === 50) { if (coordinate.side === "center") { if (coordinate.relation !== "on" || coordinate.offsetSteps > EPSILON) { - throw new RangeError('The center 50-yard reference must be exactly "on".'); + throw new RangeError( + 'The center 50-yard reference must be exactly "on".', + ); } return 0; } @@ -275,7 +277,9 @@ function sideCoordinateToXSteps(coordinate: MarchingSideCoordinate): number { } if (coordinate.side === "center") { - throw new RangeError("Only the 50-yard line can use the center side reference."); + throw new RangeError( + "Only the 50-yard line can use the center side reference.", + ); } const baseMagnitude = ((50 - coordinate.yardLine) / 5) * 8; @@ -288,9 +292,13 @@ function sideCoordinateToXSteps(coordinate: MarchingSideCoordinate): number { } const towardCenter = coordinate.relation === "inside"; if (coordinate.side === 1) { - return base + (towardCenter ? coordinate.offsetSteps : -coordinate.offsetSteps); + return ( + base + (towardCenter ? coordinate.offsetSteps : -coordinate.offsetSteps) + ); } - return base + (towardCenter ? -coordinate.offsetSteps : coordinate.offsetSteps); + return ( + base + (towardCenter ? -coordinate.offsetSteps : coordinate.offsetSteps) + ); } function frontBackCoordinateToYSteps( diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index 5ef8fa3b..e6950210 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -18,7 +18,9 @@ describe("mobile app SQLite migration", () => { expect(MOBILE_SCHEMA_VERSION).toBe(2); expect(sql).toContain("PRAGMA journal_mode = WAL"); expect(sql).toContain("PRAGMA foreign_keys = ON"); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS mobile_schema_migrations"); + expect(sql).toContain( + "CREATE TABLE IF NOT EXISTS mobile_schema_migrations", + ); expect(sql).toContain("CREATE TABLE IF NOT EXISTS drills"); expect(sql).toContain("field_preset TEXT NOT NULL DEFAULT 'football-nfhs'"); expect(sql).toContain("CREATE TABLE IF NOT EXISTS drill_pages"); @@ -87,8 +89,8 @@ describe("mobile app SQLite migration", () => { ); expect(sql).toContain("PRAGMA user_version = 2"); - const migrationUpdates = database.runAsync.mock.calls.filter(([statement]) => - String(statement).includes("SET set_number = ?"), + const migrationUpdates = database.runAsync.mock.calls.filter( + ([statement]) => String(statement).includes("SET set_number = ?"), ); expect(migrationUpdates).toHaveLength(3); expect(migrationUpdates[0][1]).toEqual([ @@ -146,7 +148,9 @@ describe("mobile app SQLite migration", () => { `Unsupported mobile database version ${MOBILE_SCHEMA_VERSION + 1}`, ); expect(database.withTransactionAsync).not.toHaveBeenCalled(); - expect(executed.join("\n")).not.toContain("CREATE TABLE IF NOT EXISTS drills"); + expect(executed.join("\n")).not.toContain( + "CREATE TABLE IF NOT EXISTS drills", + ); }); }); diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index 0b8e1bd4..ee98cf08 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -241,7 +241,10 @@ async function migrateLegacyDrillRows( for (const [index, row] of rows.entries()) { const candidate = parsed[index]; let identity: LegacyIdentity | undefined; - if (candidate?.kind === "set" && !usedPrimaryNumbers.has(candidate.number)) { + if ( + candidate?.kind === "set" && + !usedPrimaryNumbers.has(candidate.number) + ) { identity = candidate; } else if ( candidate?.kind === "subset" && @@ -274,7 +277,8 @@ async function migrateLegacyDrillRows( }, NFHS_FIELD, ); - const counts = index === 0 ? 0 : normalizeLegacyCount(row.counts_from_previous); + const counts = + index === 0 ? 0 : normalizeLegacyCount(row.counts_from_previous); await db.runAsync( `UPDATE ${DRILL_SETS_TABLE} @@ -302,9 +306,7 @@ export function parseLegacySetLabel(label: string): LegacyIdentity | undefined { const number = Number(match[1]); if (!Number.isSafeInteger(number)) return undefined; const suffix = match[2]; - return suffix - ? { number, suffix, kind: "subset" } - : { number, kind: "set" }; + return suffix ? { number, suffix, kind: "subset" } : { number, kind: "set" }; } function identityKey(identity: LegacyIdentity): string { From cb6e558cce3a2cc02760a5dbb2f07a8904519e4c Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 18:05:43 -0500 Subject: [PATCH 039/101] feat(drill-converter): Expand preview lists --- .../src/components/preview-section.tsx | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/drill-converter/src/components/preview-section.tsx b/apps/drill-converter/src/components/preview-section.tsx index 1bfdc884..7f44fdf2 100644 --- a/apps/drill-converter/src/components/preview-section.tsx +++ b/apps/drill-converter/src/components/preview-section.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; import type { CoordinateSheetImportResult } from "@eight2five/drill-importers"; import { formatSetName, type DrillDocument } from "@eight2five/drill-schema"; @@ -81,18 +81,17 @@ export function PreviewSection({ }} > - `${entity.label} · ${entity.type} · symbol ${entity.symbol}`, - )} - remainder={Math.max(0, outputDocument.entities.length - 10)} + values={outputDocument.entities.map( + (entity) => + `${entity.label} · ${entity.type} · symbol ${entity.symbol}`, + )} /> { + values={outputDocument.sets.map((set) => { const measures = set.measureRange ? set.measureRange.start === set.measureRange.end ? `m. ${set.measureRange.start}` @@ -102,7 +101,6 @@ export function PreviewSection({ set.countsFromPrevious } ct · ${measures}`; })} - remainder={Math.max(0, outputDocument.sets.length - 10)} /> @@ -172,15 +170,19 @@ function Metric({ ); } +const PREVIEW_PAGE_SIZE = 10; + function PreviewList({ title, values, - remainder, }: { readonly title: string; readonly values: readonly string[]; - readonly remainder: number; }) { + const [visibleCount, setVisibleCount] = React.useState(PREVIEW_PAGE_SIZE); + const visibleValues = values.slice(0, visibleCount); + const remainder = Math.max(0, values.length - visibleValues.length); + return ( @@ -196,7 +198,7 @@ function PreviewList({ backgroundColor: colors.surfaceMuted, }} > - {values.map((value) => ( + {visibleValues.map((value) => ( ))} {remainder > 0 ? ( - - + {remainder} more - + + setVisibleCount((count) => + Math.min(count + PREVIEW_PAGE_SIZE, values.length), + ) + } + style={({ pressed }) => ({ + alignSelf: "flex-start", + borderRadius: radius.sm, + paddingHorizontal: spacing.xs, + paddingVertical: 2, + opacity: pressed ? 0.65 : 1, + })} + > + + + {remainder} more + + ) : null} From b22964e07caaebcd2cba50592adbf774d2a4e325 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 20:28:51 -0500 Subject: [PATCH 040/101] build(testbed): Remove production EAS profile --- apps/testbed/eas.json | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/testbed/eas.json b/apps/testbed/eas.json index 5450fe93..e95e51b3 100644 --- a/apps/testbed/eas.json +++ b/apps/testbed/eas.json @@ -41,19 +41,6 @@ "simulator": true, "resourceClass": "m-medium" } - }, - "production": { - "ios": { - "resourceClass": "m-medium" - }, - "android": { - "env": { - "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - } - } } - }, - "submit": { - "production": {} } } From 9069661005e5b4133c94e9823bc3a0a9e60aebfb Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 20:34:58 -0500 Subject: [PATCH 041/101] chore(schema): Move drill schema host to eight2five.com --- apps/drill-converter/src/converter/__tests__/settings.test.ts | 2 +- packages/drill-schema/drill-document.schema.json | 4 ++-- packages/drill-schema/src/__tests__/schema.test.ts | 2 +- packages/drill-schema/src/types.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/drill-converter/src/converter/__tests__/settings.test.ts b/apps/drill-converter/src/converter/__tests__/settings.test.ts index de05a8db..84ec1a93 100644 --- a/apps/drill-converter/src/converter/__tests__/settings.test.ts +++ b/apps/drill-converter/src/converter/__tests__/settings.test.ts @@ -15,7 +15,7 @@ import { } from "../settings"; const source: DrillDocument = parseDrillDocument({ - schema: "https://eight2five.app/schema/drill", + schema: "https://eight2five.com/schema/drill", schemaVersion: "1.0.0", metadata: { title: "Imported", diff --git a/packages/drill-schema/drill-document.schema.json b/packages/drill-schema/drill-document.schema.json index d8dfdc81..d93c7c28 100644 --- a/packages/drill-schema/drill-document.schema.json +++ b/packages/drill-schema/drill-document.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://eight2five.app/schema/drill/1.0.0", + "$id": "https://eight2five.com/schema/drill/1.0.0", "title": "Eight2Five Drill Document", "type": "object", "additionalProperties": false, @@ -15,7 +15,7 @@ ], "properties": { "schema": { - "const": "https://eight2five.app/schema/drill" + "const": "https://eight2five.com/schema/drill" }, "schemaVersion": { "const": "1.0.0" diff --git a/packages/drill-schema/src/__tests__/schema.test.ts b/packages/drill-schema/src/__tests__/schema.test.ts index d1470777..c8961e79 100644 --- a/packages/drill-schema/src/__tests__/schema.test.ts +++ b/packages/drill-schema/src/__tests__/schema.test.ts @@ -11,7 +11,7 @@ import { } from ".."; const fixture: DrillDocument = { - schema: "https://eight2five.app/schema/drill", + schema: "https://eight2five.com/schema/drill", schemaVersion: "1.0.0", metadata: { title: "Part 4", diff --git a/packages/drill-schema/src/types.ts b/packages/drill-schema/src/types.ts index c48eca63..dac9d621 100644 --- a/packages/drill-schema/src/types.ts +++ b/packages/drill-schema/src/types.ts @@ -1,4 +1,4 @@ -export const DRILL_SCHEMA_URL = "https://eight2five.app/schema/drill" as const; +export const DRILL_SCHEMA_URL = "https://eight2five.com/schema/drill" as const; export const DRILL_SCHEMA_VERSION = "1.0.0" as const; export type SetKind = "set" | "subset"; From 9b1293aca9f3a90fde2328ebc90ef3868c7cc706 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 21:17:20 -0500 Subject: [PATCH 042/101] ci(ios): Provide Apple team for EAS builds --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 99ca8ce1..c2521742 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -239,6 +239,8 @@ jobs: working-directory: ${{ steps.resolve.outputs.app_dir }} env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_APPLE_TEAM_ID: ${{ vars.EXPO_APPLE_TEAM_ID }} + EXPO_APPLE_TEAM_TYPE: INDIVIDUAL EAS_LOCAL_BUILD_ARTIFACTS_DIR: ${{ steps.resolve.outputs.artifact_dir }} shell: bash run: | From ce4c963705e0ddd870ee327de2a6ba2ce31d47a8 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 21:29:13 -0500 Subject: [PATCH 043/101] ci(ios): Load Apple team ID from EAS --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2521742..51314bef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -239,7 +239,6 @@ jobs: working-directory: ${{ steps.resolve.outputs.app_dir }} env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - EXPO_APPLE_TEAM_ID: ${{ vars.EXPO_APPLE_TEAM_ID }} EXPO_APPLE_TEAM_TYPE: INDIVIDUAL EAS_LOCAL_BUILD_ARTIFACTS_DIR: ${{ steps.resolve.outputs.artifact_dir }} shell: bash From 63ac5969d2feed2c7cbdd84fa00213bdb75f1114 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 22:02:14 -0500 Subject: [PATCH 044/101] fix(navigation): Prevent dark-mode back button flash Configure Expo Router with the system navigation theme so native liquid-glass back buttons render with the correct appearance on first display. --- apps/mobile/app/_layout.tsx | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 2f61aeee..ef7f79bf 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { Stack } from "expo-router"; +import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; +import { useColorScheme } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GluestackUIProvider } from "@eight2five/ui/components/gluestack-ui-provider"; @@ -48,18 +49,21 @@ export default function MobileRootLayout() { function MobileNavigation({ backgroundColor }: { backgroundColor: string }) { const { settings } = useAppSettingsSnapshot(); + const colorScheme = useColorScheme(); return ( - - - - + + + + + + ); } From 39e29bd42227b01133b88bff4810c78859bccd38 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Sun, 2 Aug 2026 22:15:13 -0500 Subject: [PATCH 045/101] refactor(settings): Consolidate advanced options Place transition metric and field guidance controls on the main settings page and remove the extra navigation level. --- apps/mobile/app/(tabs)/settings/_layout.tsx | 1 - apps/mobile/app/(tabs)/settings/advanced.tsx | 5 -- .../settings/advanced-settings-screen.tsx | 80 ------------------- .../src/features/settings/settings-screen.tsx | 42 ++++++++-- 4 files changed, 35 insertions(+), 93 deletions(-) delete mode 100644 apps/mobile/app/(tabs)/settings/advanced.tsx delete mode 100644 apps/mobile/src/features/settings/advanced-settings-screen.tsx diff --git a/apps/mobile/app/(tabs)/settings/_layout.tsx b/apps/mobile/app/(tabs)/settings/_layout.tsx index 2b7b4216..03efa659 100644 --- a/apps/mobile/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile/app/(tabs)/settings/_layout.tsx @@ -18,7 +18,6 @@ export default function SettingsLayout() { }} > - ; -} diff --git a/apps/mobile/src/features/settings/advanced-settings-screen.tsx b/apps/mobile/src/features/settings/advanced-settings-screen.tsx deleted file mode 100644 index e3a3e498..00000000 --- a/apps/mobile/src/features/settings/advanced-settings-screen.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import { Navigation, Route } from "lucide-react-native"; -import type { TransitionMetricMode } from "@eight2five/mobile/settings"; - -import { - useAppSettingsSnapshot, - useAppSettingsStore, -} from "../../state/app-settings-store"; -import { - SettingsMessage, - SettingsScreenContainer, - SettingsSection, - SettingsSelectRow, - SettingsSwitchRow, -} from "./settings-components"; - -const TRANSITION_CHOICES = [ - { label: "Step Size", value: "step-size" }, - { label: "Crossing Counts", value: "crossing-counts" }, -] as const; - -export function AdvancedSettingsScreen() { - const store = useAppSettingsStore(); - const { status, settings, error: loadError } = useAppSettingsSnapshot(); - const [operationError, setOperationError] = React.useState(); - const disabled = status !== "ready"; - - const update = async ( - partial: - | { guidanceEnabled: boolean } - | { transitionMetricMode: TransitionMetricMode }, - ) => { - setOperationError(undefined); - try { - await store.update(partial); - } catch (cause) { - setOperationError( - cause instanceof Error ? cause : new Error(String(cause)), - ); - } - }; - - return ( - - {status === "loading" ? ( - Loading app settings… - ) : null} - {loadError || operationError ? ( - - {(operationError ?? loadError)?.message} - - ) : null} - - - icon={Route} - title="Transition metric" - description="Show Step Size or yard-line crossing counts." - value={settings.transitionMetricMode} - choices={TRANSITION_CHOICES} - onChange={(transitionMetricMode) => - void update({ transitionMetricMode }) - } - disabled={disabled} - testID="transition-metric-setting" - /> - - - void update({ guidanceEnabled })} - disabled={disabled} - testID="guidance-enabled-setting" - /> - - - ); -} diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 58f5c1bb..29b5b624 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -4,12 +4,14 @@ import { Code2, Eye, ListChecks, + Navigation, Radio, - SlidersHorizontal, + Route, } from "lucide-react-native"; import type { AppSettingsUpdate, FieldPerspective, + TransitionMetricMode, } from "@eight2five/mobile/settings"; import { useTabBarVisibility } from "../../navigation/tab-bar-visibility-context"; @@ -35,6 +37,11 @@ const PERSPECTIVE_CHOICES = [ { label: "Performer", value: "performer" }, ] as const; +const TRANSITION_CHOICES = [ + { label: "Step Size", value: "step-size" }, + { label: "Crossing Counts", value: "crossing-counts" }, +] as const; + export function SettingsScreen() { const router = useRouter(); const store = useAppSettingsStore(); @@ -116,13 +123,34 @@ export function SettingsScreen() { /> - - router.push("/(tabs)/settings/advanced")} - testID="advanced-settings-link" + + + icon={Route} + title="Transition metric" + description="Show Step Size or yard-line crossing counts." + value={settings.transitionMetricMode} + choices={TRANSITION_CHOICES} + onChange={(transitionMetricMode) => + void update({ transitionMetricMode }) + } + disabled={disabled} + testID="transition-metric-setting" + /> + + + + void update({ guidanceEnabled })} + disabled={disabled} + testID="guidance-enabled-setting" /> + + + Date: Sun, 2 Aug 2026 22:26:11 -0500 Subject: [PATCH 046/101] fix(runtime): Remove Expo and Skia deprecation warnings Configure the mobile deep-link scheme and use the immutable Skia PathBuilder API to keep native runtime output warning-free. --- apps/mobile/app.config.ts | 1 + .../src/field/render/page-dial-canvas.tsx | 24 +++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b42155f7..fbc8e8f4 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -33,6 +33,7 @@ const config: ExpoConfig = { owner: "cdguth", name: appName, slug: "eight2five", + scheme: "eight2five", platforms: ["ios", "android"], version: "0.0.0", // Field is the only route that opts into landscape; Drill and Settings diff --git a/packages/mobile/src/field/render/page-dial-canvas.tsx b/packages/mobile/src/field/render/page-dial-canvas.tsx index ddb6fb56..4a8cbfcc 100644 --- a/packages/mobile/src/field/render/page-dial-canvas.tsx +++ b/packages/mobile/src/field/render/page-dial-canvas.tsx @@ -29,19 +29,19 @@ export function FieldPageDialCanvas({ const ringThickness = diameter * 0.07; const ringRadius = diameter / 2 - ringThickness / 2; const trackPath = React.useMemo(() => { - const path = Skia.Path.Make(); const inset = ringThickness / 2; - path.addArc( - Skia.XYWHRect( - inset, - inset, - diameter - ringThickness, - diameter - ringThickness, - ), - startAngleDegrees, - usableArcDegrees, - ); - return path; + return Skia.PathBuilder.Make() + .addArc( + Skia.XYWHRect( + inset, + inset, + diameter - ringThickness, + diameter - ringThickness, + ), + startAngleDegrees, + usableArcDegrees, + ) + .build(); }, [diameter, ringThickness, startAngleDegrees, usableArcDegrees]); const knobX = useDerivedValue(() => { const angle = From c6e4c8f7afc1e159472e5f9cbc62774d996a8e11 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Mon, 3 Aug 2026 23:15:43 -0500 Subject: [PATCH 047/101] feat(field): support schema-driven marching grids --- .../src/components/details-section.tsx | 9 +- .../components/entity-settings-section.tsx | 2 +- .../src/components/preview-section.tsx | 2 +- apps/drill-converter/src/converter-screen.tsx | 8 +- .../src/converter/__tests__/settings.test.ts | 8 + .../drill-converter/src/converter/settings.ts | 19 +- .../src/converter/use-converter-controller.ts | 2 +- .../drill/__tests__/drill-management.test.ts | 26 +- .../drill/__tests__/page-form.test.ts | 37 ++ .../drill/components/drill-page-list-item.tsx | 9 +- .../components/marching-coordinate-form.tsx | 61 ++- .../features/drill/drill-editor-screen.tsx | 1 + .../src/features/drill/drill-management.ts | 7 +- .../src/features/drill/page-editor-screen.tsx | 1 + apps/mobile/src/features/drill/page-form.ts | 70 ++-- .../src/features/drill/page-management.ts | 5 +- .../drill/use-drill-editor-controller.ts | 8 +- .../drill/use-page-editor-controller.ts | 25 +- .../__tests__/effective-field-preset.test.ts | 31 ++ .../coordinate-panel-state.ts | 17 +- .../coordinate-panel/coordinate-panel.tsx | 6 +- .../coordinate-panel/drill-coordinate-row.tsx | 4 + .../coordinate-panel/live-coordinate-row.tsx | 11 +- .../features/field/effective-field-preset.ts | 10 + .../src/features/field/field-screen.tsx | 17 +- .../field/use-field-screen-controller.ts | 6 + .../features/settings/anchor-editor-form.ts | 29 +- .../settings/anchor-editor-screen.tsx | 6 +- .../settings/developer-settings-screen.tsx | 12 + .../src/features/settings/settings-screen.tsx | 21 + .../settings/use-anchor-editor-controller.ts | 72 +++- .../pans/mobile-pans-position-publisher.ts | 10 +- .../drill-schema/src/__tests__/schema.test.ts | 44 +- packages/drill-schema/src/field-presets.ts | 20 +- packages/drill-schema/src/schema.ts | 10 +- packages/drill-schema/src/types.ts | 12 +- .../mobile/src/drill/SqliteDrillRepository.ts | 29 +- .../drill/__tests__/sqlite-repository.test.ts | 62 +-- packages/mobile/src/drill/analysis.ts | 2 +- packages/mobile/src/drill/types.ts | 5 +- .../field/__tests__/anchor-position.test.ts | 51 +++ .../src/field/__tests__/field-paths.test.ts | 260 +++++++++--- .../src/field/__tests__/guidance.test.ts | 48 ++- .../src/field/__tests__/marching.test.ts | 33 ++ packages/mobile/src/field/anchor-position.ts | 120 ++++-- .../src/field/camera/field-camera-policy.ts | 8 +- packages/mobile/src/field/guidance.ts | 32 +- packages/mobile/src/field/marching.ts | 172 ++++++-- .../src/field/render/create-field-paths.ts | 284 +++++++++---- .../mobile/src/field/render/field-canvas.tsx | 19 +- .../src/field/render/field-render-tokens.ts | 6 +- .../mobile/src/field/render/field-scene.tsx | 7 +- .../src/field/render/field-static-layer.tsx | 42 +- packages/mobile/src/field/template.ts | 260 +++++++----- packages/mobile/src/mobile-repositories.ts | 10 +- .../src/settings/SqliteSettingsRepository.ts | 19 +- .../src/settings/__tests__/repository.test.ts | 78 +++- packages/mobile/src/settings/types.ts | 24 +- .../storage/__tests__/mobileDatabase.test.ts | 170 +++----- packages/mobile/src/storage/mobileDatabase.ts | 390 +++++------------- 60 files changed, 1786 insertions(+), 983 deletions(-) create mode 100644 apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts create mode 100644 apps/mobile/src/features/field/effective-field-preset.ts diff --git a/apps/drill-converter/src/components/details-section.tsx b/apps/drill-converter/src/components/details-section.tsx index dc32bb00..d04504aa 100644 --- a/apps/drill-converter/src/components/details-section.tsx +++ b/apps/drill-converter/src/components/details-section.tsx @@ -66,7 +66,7 @@ export function DetailsSection({ autoCorrect={false} spellCheck={false} error={customFieldError} - helper="Advanced: paste a v1 custom field object with matched physical and marching reference lines. The editor starts from NFHS geometry so you can change only what differs." + helper="Advanced: paste a custom field object with matched physical and marching reference lines. The editor starts from NFHS geometry so you can change only what differs." /> ) : ( @@ -113,6 +113,10 @@ function FieldPresetSummary({ preset }: { readonly preset: FieldPresetId }) { const field = getFieldPreset(preset); const frontHash = getGridReference({ type: "preset", preset }, "front-hash"); const backHash = getGridReference({ type: "preset", preset }, "back-hash"); + const backSideline = getGridReference( + { type: "preset", preset }, + "back-sideline", + ); return ( Marching grid: Front sideline 0 · Front hash{" "} {formatNumber(frontHash?.coordinateSteps)} · Back hash{" "} - {formatNumber(backHash?.coordinateSteps)} · Back sideline 84. + {formatNumber(backHash?.coordinateSteps)} · Back sideline{" "} + {formatNumber(backSideline?.coordinateSteps)}. ); diff --git a/apps/drill-converter/src/components/entity-settings-section.tsx b/apps/drill-converter/src/components/entity-settings-section.tsx index ca005632..cb34106e 100644 --- a/apps/drill-converter/src/components/entity-settings-section.tsx +++ b/apps/drill-converter/src/components/entity-settings-section.tsx @@ -156,7 +156,7 @@ export function EntitySettingsSection({ onUpdate({ explicitStraightPaths }) diff --git a/apps/drill-converter/src/components/preview-section.tsx b/apps/drill-converter/src/components/preview-section.tsx index 7f44fdf2..5b3cc751 100644 --- a/apps/drill-converter/src/components/preview-section.tsx +++ b/apps/drill-converter/src/components/preview-section.tsx @@ -41,7 +41,7 @@ export function PreviewSection({ return ( {!importResult ? ( diff --git a/apps/drill-converter/src/converter-screen.tsx b/apps/drill-converter/src/converter-screen.tsx index f6a6d781..e89da18d 100644 --- a/apps/drill-converter/src/converter-screen.tsx +++ b/apps/drill-converter/src/converter-screen.tsx @@ -96,15 +96,15 @@ export function ConverterScreen() { selectable style={{ color: colors.textMuted, fontSize: 11, lineHeight: 16 }} > - Eight2Five drill schema v1.0.0 · No account, backend, database, - analytics, or PDF upload. + Eight2Five drill schema · No account, backend, database, analytics, + or PDF upload. - v1 expects PDFs with extractable text. OCR and image-only coordinate - sheets are intentionally out of scope. + The current parser expects PDFs with extractable text. OCR and + image-only coordinate sheets are intentionally out of scope. diff --git a/apps/drill-converter/src/converter/__tests__/settings.test.ts b/apps/drill-converter/src/converter/__tests__/settings.test.ts index 84ec1a93..0dd227b7 100644 --- a/apps/drill-converter/src/converter/__tests__/settings.test.ts +++ b/apps/drill-converter/src/converter/__tests__/settings.test.ts @@ -1,5 +1,6 @@ import { COLOR_PRESETS, + FIELD_PRESET_IDS, parseDrillDocument, resolveDrillEntity, type DrillDocument, @@ -10,6 +11,7 @@ import { createDefaultConverterSettings, createEmptyRuleDraft, downloadFileName, + FIELD_PRESET_OPTIONS, inferTitleFromFileName, validateConverterSettings, } from "../settings"; @@ -57,6 +59,12 @@ describe("drill converter settings", () => { }); }); + test("exposes every schema field preset in schema order", () => { + expect(FIELD_PRESET_OPTIONS.map(({ value }) => value)).toEqual( + FIELD_PRESET_IDS, + ); + }); + test("applies metadata, props, rules, and optional explicit straight paths", () => { const symbolRule = { ...createEmptyRuleDraft("rule-1"), diff --git a/apps/drill-converter/src/converter/settings.ts b/apps/drill-converter/src/converter/settings.ts index 57ac435c..b9271532 100644 --- a/apps/drill-converter/src/converter/settings.ts +++ b/apps/drill-converter/src/converter/settings.ts @@ -1,5 +1,6 @@ import { COLOR_PRESETS, + FIELD_PRESET_IDS, countPrimarySets, fieldDefinitionSchema, getFieldPreset, @@ -13,15 +14,15 @@ import { type FieldPresetId, } from "@eight2five/drill-schema"; -export const FIELD_PRESET_OPTIONS = Object.freeze([ - { value: "football-nfhs", label: "High School (NFHS)" }, - { value: "football-ncaa", label: "College (NCAA)" }, - { value: "football-texas-uil", label: "Texas High School (UIL)" }, - { value: "football-nfl", label: "Professional (NFL)" }, -] as const satisfies readonly { - value: FieldPresetId; - label: string; -}[]); +export const FIELD_PRESET_OPTIONS = Object.freeze( + FIELD_PRESET_IDS.map((value) => ({ + value, + label: getFieldPreset(value).name, + })) satisfies readonly { + value: FieldPresetId; + label: string; + }[], +); export const ENTITY_ICON_OPTIONS = Object.freeze([ "dot", diff --git a/apps/drill-converter/src/converter/use-converter-controller.ts b/apps/drill-converter/src/converter/use-converter-controller.ts index 980d3a23..0c5aedf6 100644 --- a/apps/drill-converter/src/converter/use-converter-controller.ts +++ b/apps/drill-converter/src/converter/use-converter-controller.ts @@ -128,7 +128,7 @@ export function useConverterController() { pages.every((page) => page.items.length === 0) ) { throw new Error( - "The PDF contains no extractable text. Scanned/image-only coordinate sheets are not supported in v1.", + "The PDF contains no extractable text. Scanned/image-only coordinate sheets are not currently supported.", ); } setExtractedPages(pages); diff --git a/apps/mobile/src/features/drill/__tests__/drill-management.test.ts b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts index 2f048741..ad286764 100644 --- a/apps/mobile/src/features/drill/__tests__/drill-management.test.ts +++ b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts @@ -53,7 +53,10 @@ describe("manual drill management", () => { await expect(createNamedDrill(repository, " Show ")).resolves.toBe( created, ); - expect(repository.createDrill).toHaveBeenCalledWith("Show"); + expect(repository.createDrill).toHaveBeenCalledWith({ + name: "Show", + fieldPreset: "football-nfhs", + }); await renameNamedDrill(repository, "new", " Finale "); expect(repository.renameDrill).toHaveBeenCalledWith("new", "Finale"); @@ -66,6 +69,27 @@ describe("manual drill management", () => { ); }); + test("uses the selected default field preset for a new manual drill", async () => { + const created = { + id: "new", + name: "College Show", + fieldPreset: "football-ncaa" as const, + createdAt: 1, + updatedAt: 1, + }; + const repository = { + createDrill: jest.fn(async () => created), + } as unknown as DrillRepository; + + await expect( + createNamedDrill(repository, "College Show", "football-ncaa"), + ).resolves.toBe(created); + expect(repository.createDrill).toHaveBeenCalledWith({ + name: "College Show", + fieldPreset: "football-ncaa", + }); + }); + test("deletes the drill before refreshing cleared selection pointers", async () => { const order: string[] = []; const repository = { diff --git a/apps/mobile/src/features/drill/__tests__/page-form.test.ts b/apps/mobile/src/features/drill/__tests__/page-form.test.ts index db9c1de1..d0993db5 100644 --- a/apps/mobile/src/features/drill/__tests__/page-form.test.ts +++ b/apps/mobile/src/features/drill/__tests__/page-form.test.ts @@ -74,6 +74,43 @@ describe("structured marching coordinate form", () => { ); }); + test.each([ + ["football-nfhs", 28], + ["football-ncaa", 32], + ["football-texas-uil", 32], + ] as const)( + "%s uses its schema-defined front-hash marching reference", + (fieldPreset, expectedFrontHashSteps) => { + const result = validatePageDraft( + { + ...VALID_DRAFT, + frontBackRelation: "on", + frontBackOffsetSteps: "0", + }, + fieldPreset, + ); + expect(result.value?.position.ySteps).toBeCloseTo( + expectedFrontHashSteps, + 8, + ); + }, + ); + + test("NFL uses its schema-defined fractional front-hash marching reference", () => { + const result = validatePageDraft( + { + ...VALID_DRAFT, + frontBackRelation: "on", + frontBackOffsetSteps: "0", + }, + "football-nfl", + ); + expect(result.value?.position.ySteps).toBeCloseTo( + ((70 + 9 / 12) / 160) * 84, + 8, + ); + }); + test("initializes controls through inverse grid conversion and round trips", () => { const position = marchingCoordinateToDrillGridPoint({ side: { side: 1, yardLine: 35, relation: "outside", offsetSteps: 1.25 }, diff --git a/apps/mobile/src/features/drill/components/drill-page-list-item.tsx b/apps/mobile/src/features/drill/components/drill-page-list-item.tsx index 6f271e63..e7b042c3 100644 --- a/apps/mobile/src/features/drill/components/drill-page-list-item.tsx +++ b/apps/mobile/src/features/drill/components/drill-page-list-item.tsx @@ -5,6 +5,7 @@ import { type DrillSet, type DrillTerms, } from "@eight2five/mobile/drill"; +import type { FieldPresetId } from "@eight2five/drill-schema"; import { drillGridPointToMarchingCoordinate, formatMarchingFrontBack, @@ -34,6 +35,7 @@ export const DrillPageListItem = React.memo(function DrillPageListItem({ page, previousPage, terms: _terms, + fieldPreset, selected, busy, first, @@ -47,6 +49,7 @@ export const DrillPageListItem = React.memo(function DrillPageListItem({ previousPage?: DrillSet; /** @deprecated Sets are now the only user-facing terminology. */ terms?: DrillTerms; + fieldPreset: FieldPresetId; selected: boolean; busy: boolean; first: boolean; @@ -58,11 +61,11 @@ export const DrillPageListItem = React.memo(function DrillPageListItem({ }) { const theme = useEight2FiveTheme(); const coordinate = React.useMemo( - () => drillGridPointToMarchingCoordinate(page.position), - [page.position], + () => drillGridPointToMarchingCoordinate(page.position, fieldPreset), + [fieldPreset, page.position], ); const side = formatMarchingSide(coordinate.side); - const frontBack = formatMarchingFrontBack(coordinate.frontBack); + const frontBack = formatMarchingFrontBack(coordinate.frontBack, fieldPreset); const setName = formatSetName(page); const title = `Set ${setName}`; const measures = page.measureRange diff --git a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx index 2ae6227a..ea204efc 100644 --- a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx +++ b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx @@ -33,6 +33,12 @@ import { useEight2FiveTheme, } from "@eight2five/ui/theme"; +import { + getFieldPreset, + getGridReference, + type FieldPresetId, +} from "@eight2five/drill-schema"; + import { YARD_LINES, previewCoordinate, @@ -57,12 +63,42 @@ const SIDE_RELATION_CHOICES = [ { label: "Outside", value: "outside" }, ] as const; -const FRONT_BACK_CHOICES = [ - { label: "Front Sideline", value: "front-sideline" }, - { label: "HS FH", value: "front-hash" }, - { label: "HS BH", value: "back-hash" }, - { label: "Back Sideline", value: "back-sideline" }, -] as const; +function frontBackChoices(fieldPreset: FieldPresetId) { + const hashPrefix = fieldHashPrefix(fieldPreset); + return [ + { label: "Front Sideline", value: "front-sideline" }, + { label: `${hashPrefix} FH`, value: "front-hash" }, + { label: `${hashPrefix} BH`, value: "back-hash" }, + { label: "Back Sideline", value: "back-sideline" }, + ] as const; +} + +function fieldHashPrefix(fieldPreset: FieldPresetId): string { + switch (fieldPreset) { + case "football-nfhs": + return "HS"; + case "football-ncaa": + return "NCAA"; + case "football-texas-uil": + return "UIL"; + case "football-nfl": + return "NFL"; + } +} + +function frontBackHelper(fieldPreset: FieldPresetId): string { + const field = getFieldPreset(fieldPreset); + const frontHash = getGridReference(field, "front-hash")?.coordinateSteps; + const backHash = getGridReference(field, "back-hash")?.coordinateSteps; + if (frontHash === undefined || backHash === undefined) { + return "Offsets use the active field's marching-grid references."; + } + return `This field uses front hash ${formatStepReference(frontHash)} and back hash ${formatStepReference(backHash)}.`; +} + +function formatStepReference(value: number): string { + return Number(value.toFixed(3)).toString(); +} const FRONT_BACK_RELATION_CHOICES = [ { label: "On", value: "on" }, @@ -72,18 +108,21 @@ const FRONT_BACK_RELATION_CHOICES = [ export function MarchingCoordinateForm({ draft, + fieldPreset = "football-nfhs", showDetails = true, disabled, onChange, }: { draft: MarchingCoordinateDraft; + fieldPreset?: FieldPresetId; showDetails?: boolean; disabled: boolean; onChange(draft: MarchingCoordinateDraft): void; }) { const theme = useEight2FiveTheme(); - const validation = validatePageDraft(draft); - const preview = previewCoordinate(draft); + const validation = validatePageDraft(draft, fieldPreset); + const preview = previewCoordinate(draft, fieldPreset); + const frontBackReferenceChoices = frontBackChoices(fieldPreset); const update = ( key: Key, value: MarchingCoordinateDraft[Key], @@ -140,7 +179,7 @@ export function MarchingCoordinateForm({ error={validation.errors.measureStart} disabled={disabled} numeric - helper="Optional. Measures are performer-facing reference information in v1." + helper="Optional. Measures are performer-facing reference information." onChangeText={(value) => update("measureStart", value)} /> update("frontBackReference", value)} /> @@ -259,7 +298,7 @@ export function MarchingCoordinateForm({ error={validation.errors.frontBackOffsetSteps} disabled={disabled || draft.frontBackRelation === "on"} numeric - helper="NFHS marching references use front hash 28 and back hash 56." + helper={frontBackHelper(fieldPreset)} onChangeText={(value) => onChange({ ...draft, diff --git a/apps/mobile/src/features/drill/drill-editor-screen.tsx b/apps/mobile/src/features/drill/drill-editor-screen.tsx index a5b1cfe2..4280a9de 100644 --- a/apps/mobile/src/features/drill/drill-editor-screen.tsx +++ b/apps/mobile/src/features/drill/drill-editor-screen.tsx @@ -56,6 +56,7 @@ export function DrillEditorScreen({ drillId }: { drillId?: string }) { page={item} previousPage={controller.pages[index - 1]} terms={controller.terms} + fieldPreset={controller.drill?.fieldPreset ?? "football-nfhs"} selected={controller.selectedPageId === item.id} busy={controller.busyPageId === item.id} first={index === 0} diff --git a/apps/mobile/src/features/drill/drill-management.ts b/apps/mobile/src/features/drill/drill-management.ts index b0d6141a..b5ad69b3 100644 --- a/apps/mobile/src/features/drill/drill-management.ts +++ b/apps/mobile/src/features/drill/drill-management.ts @@ -1,4 +1,5 @@ import type { Drill, DrillRepository } from "@eight2five/mobile/drill"; +import type { FieldPresetId } from "@eight2five/drill-schema"; export const DRILL_NAME_MAX_LENGTH = 80; @@ -37,10 +38,14 @@ export async function loadDrillList( export async function createNamedDrill( repository: DrillRepository, value: string, + fieldPreset: FieldPresetId = "football-nfhs", ): Promise { const error = validateDrillName(value); if (error) throw new Error(error); - return await repository.createDrill(normalizeDrillName(value)); + return await repository.createDrill({ + name: normalizeDrillName(value), + fieldPreset, + }); } export async function renameNamedDrill( diff --git a/apps/mobile/src/features/drill/page-editor-screen.tsx b/apps/mobile/src/features/drill/page-editor-screen.tsx index 0a78aa95..c02ff87b 100644 --- a/apps/mobile/src/features/drill/page-editor-screen.tsx +++ b/apps/mobile/src/features/drill/page-editor-screen.tsx @@ -62,6 +62,7 @@ export function PageEditorScreen({ {controller.draft ? ( diff --git a/apps/mobile/src/features/drill/page-form.ts b/apps/mobile/src/features/drill/page-form.ts index 23a46e96..7ef23be2 100644 --- a/apps/mobile/src/features/drill/page-form.ts +++ b/apps/mobile/src/features/drill/page-form.ts @@ -16,6 +16,7 @@ import type { MeasureRange, SetKind, } from "@eight2five/mobile/drill"; +import type { FieldPresetId } from "@eight2five/drill-schema"; export const YARD_LINES = Object.freeze( Array.from({ length: 11 }, (_, index) => index * 5), @@ -104,25 +105,39 @@ export function createDefaultPageDraft({ }; } -export function pageToDraft(set: DrillSet): MarchingCoordinateDraft { - return setDraftFromPosition(set.position, { - number: set.number, - kind: set.kind, - suffix: set.suffix, - countsFromPrevious: set.countsFromPrevious, - measureRange: set.measureRange, - }); +export function pageToDraft( + set: DrillSet, + fieldPreset: FieldPresetId = "football-nfhs", +): MarchingCoordinateDraft { + return setDraftFromPosition( + set.position, + { + number: set.number, + kind: set.kind, + suffix: set.suffix, + countsFromPrevious: set.countsFromPrevious, + measureRange: set.measureRange, + }, + fieldPreset, + ); } -export function coordinateDraftFromFieldPoint(position: { - readonly xMeters: number; - readonly yMeters: number; -}): MarchingCoordinateDraft { - return setDraftFromPosition(fieldPointToDrillGridPoint(position), { - number: 0, - kind: "set", - countsFromPrevious: 0, - }); +export function coordinateDraftFromFieldPoint( + position: { + readonly xMeters: number; + readonly yMeters: number; + }, + fieldPreset: FieldPresetId = "football-nfhs", +): MarchingCoordinateDraft { + return setDraftFromPosition( + fieldPointToDrillGridPoint(position, fieldPreset), + { + number: 0, + kind: "set", + countsFromPrevious: 0, + }, + fieldPreset, + ); } function setDraftFromPosition( @@ -134,8 +149,9 @@ function setDraftFromPosition( readonly countsFromPrevious: number; readonly measureRange?: MeasureRange; }, + fieldPreset: FieldPresetId = "football-nfhs", ): MarchingCoordinateDraft { - const coordinate = drillGridPointToMarchingCoordinate(position); + const coordinate = drillGridPointToMarchingCoordinate(position, fieldPreset); return { setNumber: String(details.number), setKind: details.kind, @@ -157,6 +173,7 @@ function setDraftFromPosition( export function validatePageDraft( draft: MarchingCoordinateDraft, + fieldPreset: FieldPresetId = "football-nfhs", ): SetDraftValidation { const errors: SetFormErrors = {}; const setNumber = parseNonNegativeInteger( @@ -182,7 +199,7 @@ export function validatePageDraft( if (typeof counts === "string") errors.countsFromPrevious = counts; const measureRange = parseMeasureRange(draft, errors); - const coordinateResult = coordinateFromDraft(draft); + const coordinateResult = coordinateFromDraft(draft, fieldPreset); Object.assign(errors, coordinateResult.errors); if ( Object.keys(errors).length > 0 || @@ -210,13 +227,17 @@ export function validatePageDraft( export function previewCoordinate( draft: MarchingCoordinateDraft, + fieldPreset: FieldPresetId = "football-nfhs", ): CoordinatePreview | undefined { - const result = coordinateFromDraft(draft); + const result = coordinateFromDraft(draft, fieldPreset); if (!result.coordinate || Object.keys(result.errors).length > 0) return undefined; return { side: formatMarchingSide(result.coordinate.side), - frontBack: formatMarchingFrontBack(result.coordinate.frontBack), + frontBack: formatMarchingFrontBack( + result.coordinate.frontBack, + fieldPreset, + ), }; } @@ -248,7 +269,10 @@ function parseMeasureRange( return { start, end }; } -function coordinateFromDraft(draft: MarchingCoordinateDraft): { +function coordinateFromDraft( + draft: MarchingCoordinateDraft, + fieldPreset: FieldPresetId, +): { readonly errors: SetFormErrors; readonly coordinate?: MarchingCoordinate; readonly position?: DrillGridPoint; @@ -318,7 +342,7 @@ function coordinateFromDraft(draft: MarchingCoordinateDraft): { return { errors, coordinate, - position: marchingCoordinateToDrillGridPoint(coordinate), + position: marchingCoordinateToDrillGridPoint(coordinate, fieldPreset), }; } catch (cause) { errors.coordinate = cause instanceof Error ? cause.message : String(cause); diff --git a/apps/mobile/src/features/drill/page-management.ts b/apps/mobile/src/features/drill/page-management.ts index fd9135a7..befe4b85 100644 --- a/apps/mobile/src/features/drill/page-management.ts +++ b/apps/mobile/src/features/drill/page-management.ts @@ -1,4 +1,5 @@ import type { DrillRepository, DrillSet } from "@eight2five/mobile/drill"; +import type { FieldPresetId } from "@eight2five/drill-schema"; import { validatePageDraft, type MarchingCoordinateDraft } from "./page-form"; @@ -34,6 +35,7 @@ export async function savePageDraft({ placement, relativePageId, draft, + fieldPreset = "football-nfhs", }: { repository: DrillRepository; drillId: string; @@ -42,8 +44,9 @@ export async function savePageDraft({ placement: SetPlacement; relativePageId?: string; draft: MarchingCoordinateDraft; + fieldPreset?: FieldPresetId; }): Promise { - const validation = validatePageDraft(draft); + const validation = validatePageDraft(draft, fieldPreset); if (!validation.value) { const message = Object.values(validation.errors)[0] ?? "Review the set form."; diff --git a/apps/mobile/src/features/drill/use-drill-editor-controller.ts b/apps/mobile/src/features/drill/use-drill-editor-controller.ts index 1ed26a36..1aa7f3e0 100644 --- a/apps/mobile/src/features/drill/use-drill-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-editor-controller.ts @@ -70,7 +70,11 @@ export function useDrillEditorController(drillId?: string) { const repository = store.getDrillRepository(); const saved = drillId ? await renameNamedDrill(repository, drillId, name) - : await createNamedDrill(repository, name); + : await createNamedDrill( + repository, + name, + snapshot.settings.defaultFieldPreset, + ); setDrill(saved); return saved; } catch (cause) { @@ -82,7 +86,7 @@ export function useDrillEditorController(drillId?: string) { setSaving(false); } }, - [drillId, store], + [drillId, snapshot.settings.defaultFieldPreset, store], ); const makeActive = React.useCallback(async () => { diff --git a/apps/mobile/src/features/drill/use-page-editor-controller.ts b/apps/mobile/src/features/drill/use-page-editor-controller.ts index 4a82c35f..64ad8dfa 100644 --- a/apps/mobile/src/features/drill/use-page-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-page-editor-controller.ts @@ -1,6 +1,7 @@ import React from "react"; import { useFocusEffect } from "expo-router"; import { getDrillTerms, type DrillSet } from "@eight2five/mobile/drill"; +import type { FieldPresetId } from "@eight2five/drill-schema"; import { useAppSettingsSnapshot, @@ -28,6 +29,8 @@ export function usePageEditorController( const store = useAppSettingsStore(); const [page, setPage] = React.useState(); const [pages, setPages] = React.useState([]); + const [fieldPreset, setFieldPreset] = + React.useState("football-nfhs"); const [draft, setDraft] = React.useState(); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); @@ -38,7 +41,12 @@ export function usePageEditorController( if (snapshot.status !== "ready") return; try { const repository = store.getDrillRepository(); - const nextPages = await repository.listSets(drillId); + const [nextDrill, nextPages] = await Promise.all([ + repository.getDrill(drillId), + repository.listSets(drillId), + ]); + if (!nextDrill) throw new Error("This drill no longer exists."); + setFieldPreset(nextDrill.fieldPreset); setPages(nextPages); if (pageId === "new") { const ordinal = getPageCreationOrdinal( @@ -64,7 +72,7 @@ export function usePageEditorController( throw new Error("This drill entry no longer exists in the drill."); } setPage(nextPage); - setDraft(pageToDraft(nextPage)); + setDraft(pageToDraft(nextPage, nextDrill.fieldPreset)); } setError(undefined); } catch (cause) { @@ -95,6 +103,7 @@ export function usePageEditorController( placement, relativePageId, draft, + fieldPreset, }); setPage(saved); return saved; @@ -106,7 +115,16 @@ export function usePageEditorController( saveInFlight.current = false; setSaving(false); } - }, [draft, drillId, pageId, pages, placement, relativePageId, store]); + }, [ + draft, + drillId, + fieldPreset, + pageId, + pages, + placement, + relativePageId, + store, + ]); return { drillId, @@ -114,6 +132,7 @@ export function usePageEditorController( page, draft, setDraft, + fieldPreset, loading: snapshot.status === "loading" || loading, saving, terms: getDrillTerms("sets"), diff --git a/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts b/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts new file mode 100644 index 00000000..07bf2c7f --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts @@ -0,0 +1,31 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; + +import { resolveEffectiveFieldPreset } from "../effective-field-preset"; + +const PRESETS = FIELD_PRESET_IDS; + +describe("effective marching field selection", () => { + test.each(PRESETS)( + "uses %s as the preference when no drill is loaded", + (preset) => { + expect(resolveEffectiveFieldPreset(undefined, preset)).toBe(preset); + }, + ); + + test.each([ + ["football-nfhs", "football-ncaa"], + ["football-ncaa", "football-nfhs"], + ["football-texas-uil", "football-ncaa"], + ["football-nfl", "football-nfhs"], + ] as const)( + "loaded %s drill overrides %s preference", + (drillPreset, defaultPreset) => { + expect( + resolveEffectiveFieldPreset( + { fieldPreset: drillPreset }, + defaultPreset, + ), + ).toBe(drillPreset); + }, + ); +}); diff --git a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts index 3f4d8886..cd56cbd5 100644 --- a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts +++ b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts @@ -7,6 +7,7 @@ import { } from "@eight2five/mobile/field"; import { formatSetName, type DrillSet } from "@eight2five/mobile/drill"; import type { TransitionMetricMode } from "@eight2five/mobile/settings"; +import type { FieldPresetId } from "@eight2five/drill-schema"; import { getTransitionPresentation } from "../../drill/transition-presentation"; @@ -47,16 +48,18 @@ export function areCoordinatePanelControlsDisabled({ export function formatDrillCoordinateLines( position: DrillSet["position"], + fieldPreset: FieldPresetId = "football-nfhs", ): CoordinateLines { - const coordinate = drillGridPointToMarchingCoordinate(position); + const coordinate = drillGridPointToMarchingCoordinate(position, fieldPreset); return { side: formatMarchingSide(coordinate.side), - frontBack: formatMarchingFrontBack(coordinate.frontBack), + frontBack: formatMarchingFrontBack(coordinate.frontBack, fieldPreset), }; } export function getLiveCoordinatePresentation( live: FieldLivePositionState, + fieldPreset: FieldPresetId = "football-nfhs", ): LiveCoordinatePresentation { if (!live.position) { return { @@ -68,11 +71,11 @@ export function getLiveCoordinatePresentation( muted: true, }; } - const coordinate = fieldPointToMarchingCoordinate(live.position); + const coordinate = fieldPointToMarchingCoordinate(live.position, fieldPreset); return { ...(live.isStale ? { statusLabel: "Last known position" } : {}), primary: formatMarchingSide(coordinate.side), - secondary: formatMarchingFrontBack(coordinate.frontBack), + secondary: formatMarchingFrontBack(coordinate.frontBack, fieldPreset), muted: live.isStale, }; } @@ -81,11 +84,13 @@ export function getDrillCoordinatePresentation({ page, previousPage, metricMode, + fieldPreset = "football-nfhs", }: { readonly page?: DrillSet; readonly previousPage?: DrillSet; readonly metricMode: TransitionMetricMode; - /** @deprecated Sets are the only v2 terminology. */ + readonly fieldPreset?: FieldPresetId; + /** @deprecated Sets are the only supported terminology. */ readonly terminology?: unknown; }): DrillCoordinatePresentation { const metricLabel = metricMode === "step-size" ? "Step Size" : "xCounts"; @@ -116,6 +121,6 @@ export function getDrillCoordinatePresentation({ metricMode === "step-size" ? transition.stepSize : transition.crossingCounts, - coordinate: formatDrillCoordinateLines(page.position), + coordinate: formatDrillCoordinateLines(page.position, fieldPreset), }; } diff --git a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx index 2e40a24f..39f23c56 100644 --- a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx +++ b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx @@ -9,6 +9,7 @@ import type { DrillTerminology, } from "@eight2five/mobile/drill"; import type { TransitionMetricMode } from "@eight2five/mobile/settings"; +import type { FieldPresetId } from "@eight2five/drill-schema"; import { ConnectionIndicator } from "./connection-indicator"; import { DrillCoordinateRow } from "./drill-coordinate-row"; @@ -25,6 +26,7 @@ export interface CoordinatePanelProps { readonly previousPage?: DrillPage; readonly terminology: DrillTerminology; readonly metricMode: TransitionMetricMode; + readonly fieldPreset: FieldPresetId; readonly controlsDisabled: boolean; readonly error?: Error; readonly onSelectDrill: (drillId: string | null) => void; @@ -41,6 +43,7 @@ export function CoordinatePanel({ previousPage, terminology, metricMode, + fieldPreset, controlsDisabled, error, onSelectDrill, @@ -62,7 +65,7 @@ export function CoordinatePanel({ > - + {drillFeaturesEnabled ? ( void; @@ -77,6 +80,7 @@ export function DrillCoordinateRow({ page, previousPage, metricMode, + fieldPreset, }); const metadata = ( diff --git a/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx b/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx index 6b4456ad..da25b1be 100644 --- a/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx +++ b/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx @@ -1,11 +1,18 @@ import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import type { FieldLivePositionState } from "@eight2five/mobile/field"; +import type { FieldPresetId } from "@eight2five/drill-schema"; import { getLiveCoordinatePresentation } from "./coordinate-panel-state"; -export function LiveCoordinateRow({ live }: { live: FieldLivePositionState }) { - const presentation = getLiveCoordinatePresentation(live); +export function LiveCoordinateRow({ + live, + fieldPreset, +}: { + live: FieldLivePositionState; + fieldPreset: FieldPresetId; +}) { + const presentation = getLiveCoordinatePresentation(live, fieldPreset); const color = presentation.muted ? "rgba(255,255,255,0.58)" : "#FFFFFF"; return ( | undefined, + defaultFieldPreset: FieldPresetId, +): FieldPresetId { + return activeDrill?.fieldPreset ?? defaultFieldPreset; +} diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index ec57c725..682d3c1d 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -11,7 +11,7 @@ import { } from "@eight2five/mobile/field"; import { formatSetName } from "@eight2five/mobile/drill"; import { - FIELD_FIVE_YARD_GRID_COLOR, + FIELD_FOUR_STEP_GRID_COLOR, FieldCanvas, } from "@eight2five/mobile/field/render"; import { useEight2FiveTheme } from "@eight2five/ui/theme"; @@ -73,14 +73,17 @@ export function FieldScreen({ }; const targetPosition = shouldShowFieldTarget(drillOverlayState) && controller.selectedPage - ? drillGridPointToFieldPoint(controller.selectedPage.position) + ? drillGridPointToFieldPoint( + controller.selectedPage.position, + controller.fieldPreset, + ) : undefined; const palette = React.useMemo( () => ({ canvasBackground: theme.background, stepGrid: theme.textSubtle, fieldBackground: theme.surfaceRaised, - fiveYardGrid: FIELD_FIVE_YARD_GRID_COLOR, + fourStepGrid: FIELD_FOUR_STEP_GRID_COLOR, fieldLines: theme.textMuted, fieldNumbers: theme.textMuted, livePosition: theme.accent, @@ -102,11 +105,16 @@ export function FieldScreen({ defaultViewport={controller.defaultViewport} onViewportChange={controller.commitViewport} palette={palette} + fieldPreset={controller.fieldPreset} livePosition={livePositionValue} targetPosition={targetPosition} guidanceVisible={shouldShowFieldGuidance(drillOverlayState)} anchors={anchors} anchorOverlayOptions={anchorOverlayOptions} + showPerimeterStepGrid={ + controller.settings.developerModeEnabled && + controller.settings.showPerimeterStepGrid + } /> } hud={ @@ -120,6 +128,7 @@ export function FieldScreen({ previousPage={controller.previousPage} terminology="sets" metricMode={controller.settings.transitionMetricMode} + fieldPreset={controller.fieldPreset} controlsDisabled={areCoordinatePanelControlsDisabled({ settingsReady: controller.settingsStatus === "ready", loadingDrills: controller.loadingDrills, @@ -146,7 +155,7 @@ export function FieldScreen({ pageCount={controller.pages.length} terminology="sets" activeColor={theme.accent} - trackColor={FIELD_FIVE_YARD_GRID_COLOR} + trackColor={FIELD_FOUR_STEP_GRID_COLOR} onSelectIndex={(index) => void controller.selectPageAtIndex(index) } diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts index bbd7b29c..00df6899 100644 --- a/apps/mobile/src/features/field/use-field-screen-controller.ts +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -9,6 +9,7 @@ import { useAppSettingsSnapshot, useAppSettingsStore, } from "../../state/app-settings-store"; +import { resolveEffectiveFieldPreset } from "./effective-field-preset"; let committedFieldViewport: FieldViewport | undefined; @@ -137,6 +138,10 @@ export function useFieldScreenController() { (page) => page.id === effectiveSelectedPageId, ); const selectedPage = selectedIndex >= 0 ? pages[selectedIndex] : undefined; + const fieldPreset = resolveEffectiveFieldPreset( + activeDrill, + snapshot.settings.defaultFieldPreset, + ); return { width, @@ -152,6 +157,7 @@ export function useFieldScreenController() { selectedIndex, selectedPage, previousPage: selectedIndex > 0 ? pages[selectedIndex - 1] : undefined, + fieldPreset, loadingDrills, selectionBusy, error: fieldError ?? snapshot.error, diff --git a/apps/mobile/src/features/settings/anchor-editor-form.ts b/apps/mobile/src/features/settings/anchor-editor-form.ts index d4190d34..ee5f833e 100644 --- a/apps/mobile/src/features/settings/anchor-editor-form.ts +++ b/apps/mobile/src/features/settings/anchor-editor-form.ts @@ -1,3 +1,4 @@ +import type { FieldPresetId } from "@eight2five/drill-schema"; import { ANCHOR_POSITION_REFERENCE_LABELS, ANCHOR_POSITION_REFERENCES, @@ -49,22 +50,26 @@ export const ANCHOR_UNIT_CHOICES: readonly { { label: "Feet", value: "feet" }, ]; -export function createAnchorEditorDrafts(position?: AnchorFieldPosition): { +export function createAnchorEditorDrafts( + position?: AnchorFieldPosition, + fieldPreset: FieldPresetId = "football-nfhs", +): { readonly marching: MarchingAnchorDraft; readonly standard: StandardAnchorPositionDraft; } { - const center = getAnchorPositionReferencePoint("center-field"); + const center = getAnchorPositionReferencePoint("center-field", fieldPreset); const initial = position ?? { ...center, zMeters: DEFAULT_ANCHOR_HEIGHT_METERS, }; const coordinate = position - ? coordinateDraftFromFieldPoint(position) + ? coordinateDraftFromFieldPoint(position, fieldPreset) : createDefaultPageDraft({ ordinal: 0, suggestedNumber: 0 }); const standard = anchorFieldPositionToStandard( initial, "center-field", "meters", + fieldPreset, ); return { marching: { @@ -84,8 +89,9 @@ export function createAnchorEditorDrafts(position?: AnchorFieldPosition): { export function validateMarchingAnchorDraft( draft: MarchingAnchorDraft, + fieldPreset: FieldPresetId = "football-nfhs", ): AnchorDraftValidation { - const coordinate = validatePageDraft(draft.coordinate); + const coordinate = validatePageDraft(draft.coordinate, fieldPreset); const errors: Record = { ...coordinate.errors }; const height = Number(draft.height); if (!draft.height.trim() || !Number.isFinite(height)) { @@ -100,6 +106,7 @@ export function validateMarchingAnchorDraft( position: anchorFieldPositionFromMarchingCoordinate( coordinate.value.coordinate, anchorPositionUnitsToMeters(height, draft.heightUnit), + fieldPreset, ), }; } catch (cause) { @@ -132,17 +139,19 @@ export function convertMarchingHeightUnit( export function validateStandardAnchorDraft( draft: StandardAnchorPositionDraft, + fieldPreset: FieldPresetId = "football-nfhs", ): AnchorDraftValidation { - const result = parseAnchorPositionDraft(draft); + const result = parseAnchorPositionDraft(draft, fieldPreset); return { errors: result.errors, position: result.value }; } export function formatAnchorCanonicalPreview( position: AnchorFieldPosition | undefined, + fieldPreset: FieldPresetId = "football-nfhs", ): { readonly marching: string; readonly meters: string } | undefined { if (!position) return undefined; return { - marching: formatMarchingCoordinate(position), + marching: formatMarchingCoordinate(position, fieldPreset), meters: `X ${position.xMeters.toFixed(3)} m · Y ${position.yMeters.toFixed(3)} m · Z ${position.zMeters.toFixed(3)} m`, }; } @@ -151,8 +160,14 @@ export function standardDraftFromPosition( position: AnchorFieldPosition, reference: AnchorPositionReference, unit: AnchorPositionUnit, + fieldPreset: FieldPresetId = "football-nfhs", ): StandardAnchorPositionDraft { - const standard = anchorFieldPositionToStandard(position, reference, unit); + const standard = anchorFieldPositionToStandard( + position, + reference, + unit, + fieldPreset, + ); return { reference, unit, diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx index f09cd9a1..bd647ef5 100644 --- a/apps/mobile/src/features/settings/anchor-editor-screen.tsx +++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx @@ -39,7 +39,10 @@ export function AnchorEditorScreen({ }) { const theme = useEight2FiveTheme(); const controller = useAnchorEditorController(anchorId); - const preview = formatAnchorCanonicalPreview(controller.validation.position); + const preview = formatAnchorCanonicalPreview( + controller.validation.position, + controller.fieldPreset, + ); if (!controller.developerModeEnabled) { return ( @@ -100,6 +103,7 @@ export function AnchorEditorScreen({ diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx index 63a469f9..dde18fef 100644 --- a/apps/mobile/src/features/settings/developer-settings-screen.tsx +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -5,6 +5,7 @@ import { CircleDashed, Code2, Database, + Grid3X3, MapPinned, RefreshCw, Radio, @@ -89,6 +90,7 @@ export function DeveloperSettingsScreen() { const updateOverlay = async (partial: { showCachedAnchorGeometry?: boolean; showComfortableAnchorRange?: boolean; + showPerimeterStepGrid?: boolean; comfortableAnchorRangeMeters?: number; }) => { setOperationError(undefined); @@ -213,6 +215,16 @@ export function DeveloperSettingsScreen() { + + void updateOverlay({ showPerimeterStepGrid }) + } + testID="show-perimeter-step-grid-setting" + /> ({ + label: getFieldPreset(value).name, + value, +})) satisfies readonly { label: string; value: FieldPresetId }[]; + const TRANSITION_CHOICES = [ { label: "Step Size", value: "step-size" }, { label: "Crossing Counts", value: "crossing-counts" }, @@ -111,6 +122,16 @@ export function SettingsScreen() { + + icon={Map} + title="Default marching field" + description="Used when no drill is loaded and for new manual drills. A loaded drill overrides this default." + value={settings.defaultFieldPreset} + choices={FIELD_PRESET_CHOICES} + onChange={(defaultFieldPreset) => void update({ defaultFieldPreset })} + disabled={disabled} + testID="default-field-preset-setting" + /> icon={Eye} title="Field perspective" diff --git a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts index f4e026b5..8ab2af30 100644 --- a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts +++ b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts @@ -7,11 +7,15 @@ import type { } from "@eight2five/mobile/field"; import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; -import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; import { useMobilePansSnapshot, useMobilePansStore, } from "../../pans/mobile-pans-context"; +import { resolveEffectiveFieldPreset } from "../field/effective-field-preset"; import { createAnchorEditorDrafts, convertMarchingHeightUnit, @@ -24,9 +28,13 @@ import { export function useAnchorEditorController(anchorId: string) { const settings = useAppSettingsSnapshot(); + const settingsStore = useAppSettingsStore(); const pans = useMobilePansSnapshot(); const pansStore = useMobilePansStore(); const [anchor, setAnchor] = React.useState(); + const [fieldPreset, setFieldPreset] = React.useState( + settings.settings.defaultFieldPreset, + ); const [mode, setModeState] = React.useState("marching"); const [marchingDraft, setMarchingDraft] = React.useState( () => createAnchorEditorDrafts().marching, @@ -40,11 +48,18 @@ export function useAnchorEditorController(anchorId: string) { const [error, setError] = React.useState(); const load = React.useCallback(async () => { - if (pans.initialization !== "ready") return; + if (pans.initialization !== "ready" || settings.status !== "ready") return; setLoading(true); setError(undefined); try { - const next = await pansStore.getRuntime().repository.getDevice(anchorId); + const [next, activeDrill] = await Promise.all([ + pansStore.getRuntime().repository.getDevice(anchorId), + settings.settings.activeDrillId + ? settingsStore + .getDrillRepository() + .getDrill(settings.settings.activeDrillId) + : Promise.resolve(undefined), + ]); if ( !next || (next.role !== "anchor" && next.lastKnownConfig?.role !== "anchor") @@ -55,8 +70,13 @@ export function useAnchorEditorController(anchorId: string) { next.lastKnownConfig?.role === "anchor" ? next.lastKnownConfig.position : undefined; - const drafts = createAnchorEditorDrafts(position); + const nextFieldPreset = resolveEffectiveFieldPreset( + activeDrill, + settings.settings.defaultFieldPreset, + ); + const drafts = createAnchorEditorDrafts(position, nextFieldPreset); setAnchor(next); + setFieldPreset(nextFieldPreset); setMarchingDraft(drafts.marching); setStandardDraft(drafts.standard); } catch (cause) { @@ -64,7 +84,15 @@ export function useAnchorEditorController(anchorId: string) { } finally { setLoading(false); } - }, [anchorId, pans.initialization, pansStore]); + }, [ + anchorId, + pans.initialization, + pansStore, + settings.settings.activeDrillId, + settings.settings.defaultFieldPreset, + settings.status, + settingsStore, + ]); useFocusEffect( React.useCallback(() => { @@ -74,8 +102,8 @@ export function useAnchorEditorController(anchorId: string) { const validation = mode === "marching" - ? validateMarchingAnchorDraft(marchingDraft) - : validateStandardAnchorDraft(standardDraft); + ? validateMarchingAnchorDraft(marchingDraft, fieldPreset) + : validateStandardAnchorDraft(standardDraft, fieldPreset); const setMode = (nextMode: AnchorEditorMode) => { if (nextMode === mode) return; @@ -87,10 +115,13 @@ export function useAnchorEditorController(anchorId: string) { position, standardDraft.reference, standardDraft.unit, + fieldPreset, ), ); } else { - setMarchingDraft(createAnchorEditorDrafts(position).marching); + setMarchingDraft( + createAnchorEditorDrafts(position, fieldPreset).marching, + ); } } setSaved(false); @@ -100,19 +131,35 @@ export function useAnchorEditorController(anchorId: string) { const updateStandardReference = ( reference: StandardAnchorPositionDraft["reference"], ) => { - const position = validateStandardAnchorDraft(standardDraft).position; + const position = validateStandardAnchorDraft( + standardDraft, + fieldPreset, + ).position; setStandardDraft( position - ? standardDraftFromPosition(position, reference, standardDraft.unit) + ? standardDraftFromPosition( + position, + reference, + standardDraft.unit, + fieldPreset, + ) : { ...standardDraft, reference }, ); }; const updateStandardUnit = (unit: AnchorPositionUnit) => { - const position = validateStandardAnchorDraft(standardDraft).position; + const position = validateStandardAnchorDraft( + standardDraft, + fieldPreset, + ).position; setStandardDraft( position - ? standardDraftFromPosition(position, standardDraft.reference, unit) + ? standardDraftFromPosition( + position, + standardDraft.reference, + unit, + fieldPreset, + ) : { ...standardDraft, unit }, ); }; @@ -137,6 +184,7 @@ export function useAnchorEditorController(anchorId: string) { developerModeEnabled: settings.settings.developerModeEnabled, connectionState: pans.connectionState, anchor, + fieldPreset, mode, marchingDraft, standardDraft, diff --git a/apps/mobile/src/pans/mobile-pans-position-publisher.ts b/apps/mobile/src/pans/mobile-pans-position-publisher.ts index e91a06a0..7ccd122a 100644 --- a/apps/mobile/src/pans/mobile-pans-position-publisher.ts +++ b/apps/mobile/src/pans/mobile-pans-position-publisher.ts @@ -1,4 +1,3 @@ -import { formatMarchingCoordinate } from "@eight2five/mobile/field"; import type { FieldPoint } from "@eight2five/mobile/field"; import type { PansPositionStreamSample } from "@eight2five/mobile/pans-manager"; import type { SharedValue } from "react-native-reanimated"; @@ -25,7 +24,6 @@ export class MobilePansPositionPublisher { private positionValue?: SharedValue; private staleTimer?: ReturnType; private lastHudPublicationAt = 0; - private lastHudKey?: string; private sampleTimes: number[] = []; constructor(private readonly host: PositionPublisherHost) {} @@ -49,14 +47,9 @@ export class MobilePansPositionPublisher { ); this.sampleTimes.push(receivedAt); this.scheduleStale(generation); - const hudKey = formatMarchingCoordinate(fieldPoint); - if ( - hudKey === this.lastHudKey && - receivedAt - this.lastHudPublicationAt < HUD_PUBLICATION_INTERVAL_MS - ) { + if (receivedAt - this.lastHudPublicationAt < HUD_PUBLICATION_INTERVAL_MS) { return; } - this.lastHudKey = hudKey; this.lastHudPublicationAt = receivedAt; const snapshot = this.host.getSnapshot(); this.host.publish({ @@ -90,7 +83,6 @@ export class MobilePansPositionPublisher { resetStreamState(): void { this.sampleTimes = []; - this.lastHudKey = undefined; this.lastHudPublicationAt = 0; this.cancelStaleTimer(); const snapshot = this.host.getSnapshot(); diff --git a/packages/drill-schema/src/__tests__/schema.test.ts b/packages/drill-schema/src/__tests__/schema.test.ts index c8961e79..dadca60c 100644 --- a/packages/drill-schema/src/__tests__/schema.test.ts +++ b/packages/drill-schema/src/__tests__/schema.test.ts @@ -1,8 +1,13 @@ import { COLOR_PRESETS, + FIELD_PRESETS, + FIELD_PRESET_IDS, countPrimarySets, drillGridToPhysicalPoint, formatSetName, + getFieldPreset, + getGridReference, + isFieldPresetId, parseDrillDocument, physicalPointToDrillGrid, resolveDrillEntity, @@ -76,7 +81,7 @@ const fixture: DrillDocument = { }; describe("drill schema", () => { - it("accepts the v1 set/subset model and round trips JSON", () => { + it("accepts the current set/subset model and round trips JSON", () => { const parsed = parseDrillDocument(fixture); expect(countPrimarySets(parsed.sets)).toBe(2); expect(formatSetName(parsed.sets[1])).toBe("31A"); @@ -137,6 +142,43 @@ describe("drill schema", () => { }); }); + it("exposes all field preset ids from one canonical registry", () => { + expect(FIELD_PRESET_IDS).toEqual(Object.keys(FIELD_PRESETS)); + for (const id of FIELD_PRESET_IDS) expect(isFieldPresetId(id)).toBe(true); + expect(isFieldPresetId("football-made-up")).toBe(false); + }); + + it.each(FIELD_PRESET_IDS)( + "%s uses the canonical 160 by 84 marching-grid bounds", + (preset) => { + expect(getFieldPreset(preset).marchingGrid.bounds).toEqual({ + minXSteps: -80, + maxXSteps: 80, + minYSteps: 0, + maxYSteps: 84, + }); + }, + ); + + it("keeps the conventional NFHS lateral references at 0/28/56/84", () => { + const field = getFieldPreset("football-nfhs"); + expect(getGridReference(field, "front-sideline")?.coordinateSteps).toBe(0); + expect(getGridReference(field, "front-hash")?.coordinateSteps).toBe(28); + expect(getGridReference(field, "back-hash")?.coordinateSteps).toBe(56); + expect(getGridReference(field, "back-sideline")?.coordinateSteps).toBe(84); + }); + + it.each(["football-ncaa", "football-texas-uil"] as const)( + "%s keeps its schema-defined 0/32/52/84 lateral references", + (preset) => { + const field = getFieldPreset(preset); + expect(getGridReference(field, "front-sideline")?.coordinateSteps).toBe(0); + expect(getGridReference(field, "front-hash")?.coordinateSteps).toBe(32); + expect(getGridReference(field, "back-hash")?.coordinateSteps).toBe(52); + expect(getGridReference(field, "back-sideline")?.coordinateSteps).toBe(84); + }, + ); + it("maps the conventional NFHS front hash to exact physical geometry", () => { const physical = drillGridToPhysicalPoint( { xSteps: 0, ySteps: 28 }, diff --git a/packages/drill-schema/src/field-presets.ts b/packages/drill-schema/src/field-presets.ts index 5bfe0208..e58051bc 100644 --- a/packages/drill-schema/src/field-presets.ts +++ b/packages/drill-schema/src/field-presets.ts @@ -1,8 +1,9 @@ -import type { - FieldPresetId, - MarchingReferenceLine, - PhysicalReferenceLine, - ResolvedFieldDefinition, +import { + FIELD_PRESET_IDS, + type FieldPresetId, + type MarchingReferenceLine, + type PhysicalReferenceLine, + type ResolvedFieldDefinition, } from "./types"; const FEET_TO_METERS = 0.3048; @@ -141,6 +142,15 @@ function makeFootballPreset({ }); } +const FIELD_PRESET_ID_SET = new Set(FIELD_PRESET_IDS); + +export function isFieldPresetId(value: unknown): value is FieldPresetId { + return ( + typeof value === "string" && + FIELD_PRESET_ID_SET.has(value as FieldPresetId) + ); +} + /** * Presets keep exact physical football geometry separate from conventional * marching-grid references. The grid is intentionally not a literal inches diff --git a/packages/drill-schema/src/schema.ts b/packages/drill-schema/src/schema.ts index 966e6ceb..e815c47c 100644 --- a/packages/drill-schema/src/schema.ts +++ b/packages/drill-schema/src/schema.ts @@ -4,6 +4,7 @@ import { SET_SUFFIX_PATTERN } from "./sets"; import { DRILL_SCHEMA_URL, DRILL_SCHEMA_VERSION, + FIELD_PRESET_IDS, type DrillDocument, } from "./types"; @@ -266,12 +267,7 @@ const marchingGridSchema = z const presetFieldSchema = z .object({ type: z.literal("preset"), - preset: z.enum([ - "football-nfhs", - "football-ncaa", - "football-texas-uil", - "football-nfl", - ]), + preset: z.enum(FIELD_PRESET_IDS), }) .strict(); @@ -549,7 +545,7 @@ export const drillDocumentSchema: z.ZodType = z context.addIssue({ code: z.ZodIssueCode.custom, path: ["paths", index, "toSetId"], - message: "Version 1 paths may only connect consecutive set entries.", + message: "Paths may only connect consecutive set entries.", }); } if ( diff --git a/packages/drill-schema/src/types.ts b/packages/drill-schema/src/types.ts index dac9d621..0e84c056 100644 --- a/packages/drill-schema/src/types.ts +++ b/packages/drill-schema/src/types.ts @@ -12,11 +12,13 @@ export type EntityIcon = | "hexagon" | "cross"; -export type FieldPresetId = - | "football-nfhs" - | "football-ncaa" - | "football-texas-uil" - | "football-nfl"; +export const FIELD_PRESET_IDS = Object.freeze([ + "football-nfhs", + "football-ncaa", + "football-texas-uil", + "football-nfl", +] as const); +export type FieldPresetId = (typeof FIELD_PRESET_IDS)[number]; export interface DrillMetadata { readonly title: string; diff --git a/packages/mobile/src/drill/SqliteDrillRepository.ts b/packages/mobile/src/drill/SqliteDrillRepository.ts index 88dd3b88..af1c1be1 100644 --- a/packages/mobile/src/drill/SqliteDrillRepository.ts +++ b/packages/mobile/src/drill/SqliteDrillRepository.ts @@ -1,12 +1,13 @@ import { formatSetName, + isFieldPresetId, type DrillGridPoint, + type FieldPresetId, type MeasureRange, type SetKind, } from "@eight2five/drill-schema"; import type { SQLiteDatabase } from "expo-sqlite"; -import { drillGridPointToFieldPoint } from "../field/marching"; import { APP_SETTINGS_TABLE, DRILLS_TABLE, @@ -24,7 +25,7 @@ export interface CreateDrillInput { readonly name: string; readonly createdAt?: number; readonly updatedAt?: number; - readonly fieldPreset?: "football-nfhs"; + readonly fieldPreset?: FieldPresetId; } export interface CreateDrillSetDetails { @@ -173,10 +174,8 @@ export class SqliteDrillRepository implements DrillRepository { ); const id = assertId(input.id ?? this.idFactory(), "Drill id"); const fieldPreset = input.fieldPreset ?? "football-nfhs"; - if (fieldPreset !== "football-nfhs") { - throw invalidInput( - "The mobile MVP currently supports the NFHS field preset.", - ); + if (!isFieldPresetId(fieldPreset)) { + throw invalidInput(`Unsupported field preset ${String(fieldPreset)}.`); } await this.db.runAsync( @@ -297,6 +296,7 @@ export class SqliteDrillRepository implements DrillRepository { throw invalidInput("The first set must have zero counts from previous."); } + await this.requireDrill(current.drillId); await this.db.withTransactionAsync(async () => { await this.updateSetRow(next); await this.validateSetStructure(current.drillId); @@ -511,14 +511,12 @@ export class SqliteDrillRepository implements DrillRepository { private async insertSetRow( set: NormalizedCreateSet & { id: string; ordinal: number }, ) { - const physical = drillGridPointToFieldPoint(set.position); - const label = formatSetName(set); await this.db.runAsync( `INSERT INTO ${DRILL_SETS_TABLE} (id, drill_id, ordinal, set_number, set_suffix, set_kind, counts_from_previous, measure_start, measure_end, - x_steps, y_steps, facing_degrees, label, x_meters, y_meters) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + x_steps, y_steps, facing_degrees) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ set.id, set.drillId, @@ -532,20 +530,16 @@ export class SqliteDrillRepository implements DrillRepository { set.position.xSteps, set.position.ySteps, set.facingDegrees ?? null, - label, - physical.xMeters, - physical.yMeters, ], ); } private async updateSetRow(set: DrillSet): Promise { - const physical = drillGridPointToFieldPoint(set.position); await this.db.runAsync( `UPDATE ${DRILL_SETS_TABLE} SET set_number = ?, set_suffix = ?, set_kind = ?, counts_from_previous = ?, measure_start = ?, measure_end = ?, x_steps = ?, y_steps = ?, - facing_degrees = ?, label = ?, x_meters = ?, y_meters = ? + facing_degrees = ? WHERE id = ?`, [ set.number, @@ -557,9 +551,6 @@ export class SqliteDrillRepository implements DrillRepository { set.position.xSteps, set.position.ySteps, set.facingDegrees ?? null, - formatSetName(set), - physical.xMeters, - physical.yMeters, set.id, ], ); @@ -814,7 +805,7 @@ function nullableIdFromSql(value: SqlValue | undefined): string | null { function toDrill(row: Row): Drill { const fieldPreset = rowText(row.field_preset, "drill field_preset"); - if (fieldPreset !== "football-nfhs") { + if (!isFieldPresetId(fieldPreset)) { throw new MobileRowError(`Unsupported mobile field preset ${fieldPreset}.`); } return { diff --git a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts index 8f0265f1..54edd69f 100644 --- a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts +++ b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts @@ -1,3 +1,4 @@ +import { FIELD_PRESET_IDS, type FieldPresetId } from "@eight2five/drill-schema"; import type { SQLiteDatabase } from "expo-sqlite"; import { SqliteDrillRepository } from "../SqliteDrillRepository"; @@ -62,6 +63,24 @@ describe("SqliteDrillRepository", () => { ]); }); + test.each(FIELD_PRESET_IDS)( + "round-trips the %s field preset", + async (fieldPreset) => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => `drill-${fieldPreset}`, + timeFactory: () => 1, + }); + + await expect( + repository.createDrill({ name: fieldPreset, fieldPreset }), + ).resolves.toMatchObject({ fieldPreset }); + await expect(repository.listDrills()).resolves.toEqual([ + expect.objectContaining({ fieldPreset }), + ]); + }, + ); + test("inserts, reorders, updates, and deletes sets through transactions", async () => { const fake = new DrillFakeDatabase(); const ids = ["drill", "set-a", "set-b", "set-inserted"]; @@ -238,7 +257,7 @@ describe("SqliteDrillRepository", () => { type FakeDrillRow = { id: string; name: string; - field_preset: "football-nfhs"; + field_preset: FieldPresetId; created_at: number; updated_at: number; }; @@ -256,9 +275,6 @@ type FakeSetRow = { x_steps: number; y_steps: number; facing_degrees: number | null; - label: string; - x_meters: number; - y_meters: number; }; class DrillFakeDatabase { @@ -268,11 +284,13 @@ class DrillFakeDatabase { drill_features_enabled: 1, drill_terminology: "sets", field_perspective: "director", + default_field_preset: "football-nfhs", transition_metric_mode: "step-size", guidance_enabled: 1, developer_mode_enabled: 0, show_cached_anchor_geometry: 0, show_comfortable_anchor_range: 0, + show_perimeter_step_grid: 0, comfortable_anchor_range_meters: 20, active_drill_id: null as string | null, selected_drill_page_id: null as string | null, @@ -330,17 +348,17 @@ class DrillFakeDatabase { const row = this.drills.get(String(params[0])); return row ? { ...row } : null; } - if (sql.includes("SELECT id FROM drill_pages") && sql.includes("LIMIT 1")) { + if (sql.includes("SELECT id FROM drill_sets") && sql.includes("LIMIT 1")) { const row = [...this.sets.values()] .filter((set) => set.drill_id === params[0]) .sort((left, right) => left.ordinal - right.ordinal)[0]; return row ? { id: row.id } : null; } - if (sql.includes("SELECT drill_id FROM drill_pages")) { + if (sql.includes("SELECT drill_id FROM drill_sets")) { const row = this.sets.get(String(params[0])); return row ? { drill_id: row.drill_id } : null; } - if (sql.includes("FROM drill_pages")) { + if (sql.includes("FROM drill_sets")) { const row = this.sets.get(String(params[0])); return row ? { ...row } : null; } @@ -358,7 +376,7 @@ class DrillFakeDatabase { ) .map((row) => ({ ...row })); } - if (sql.includes("FROM drill_pages")) { + if (sql.includes("FROM drill_sets")) { const rows = [...this.sets.values()] .filter((set) => set.drill_id === params[0]) .sort( @@ -366,7 +384,7 @@ class DrillFakeDatabase { left.ordinal - right.ordinal || left.id.localeCompare(right.id), ); return rows.map((row) => - /^\s*SELECT id\s+FROM drill_pages/m.test(sql) + /^\s*SELECT id\s+FROM drill_sets/m.test(sql) ? { id: row.id } : { ...row }, ); @@ -379,7 +397,7 @@ class DrillFakeDatabase { const [id, name, fieldPreset, createdAt, updatedAt] = params as [ string, string, - "football-nfhs", + FieldPresetId, number, number, ]; @@ -390,7 +408,7 @@ class DrillFakeDatabase { created_at: createdAt, updated_at: updatedAt, }); - } else if (sql.includes("INSERT INTO drill_pages")) { + } else if (sql.includes("INSERT INTO drill_sets")) { const [ id, drillId, @@ -404,9 +422,6 @@ class DrillFakeDatabase { xSteps, ySteps, facingDegrees, - label, - xMeters, - yMeters, ] = params as [ string, string, @@ -420,9 +435,6 @@ class DrillFakeDatabase { number, number, number | null, - string, - number, - number, ]; this.sets.set(id, { id, @@ -437,9 +449,6 @@ class DrillFakeDatabase { x_steps: xSteps, y_steps: ySteps, facing_degrees: facingDegrees, - label, - x_meters: xMeters, - y_meters: yMeters, }); } else if (sql.includes("INSERT OR IGNORE INTO app_settings")) { // Singleton already exists in this fake. @@ -458,7 +467,7 @@ class DrillFakeDatabase { this.settings.active_drill_id = null; this.settings.selected_drill_page_id = null; } - } else if (sql.includes("DELETE FROM drill_pages")) { + } else if (sql.includes("DELETE FROM drill_sets")) { const id = String(params[0]); this.sets.delete(id); if (this.settings.selected_drill_page_id === id) { @@ -470,7 +479,7 @@ class DrillFakeDatabase { if (row) this.drills.set(id, { ...row, name, updated_at: updatedAt }); } else if (sql.includes("UPDATE app_settings")) { this.updateSettings(sql, params); - } else if (sql.includes("UPDATE drill_pages")) { + } else if (sql.includes("UPDATE drill_sets")) { this.updateSets(sql, params); } return { lastInsertRowId: 1, changes: 1 }; @@ -559,9 +568,6 @@ class DrillFakeDatabase { xSteps, ySteps, facingDegrees, - label, - xMeters, - yMeters, id, ] = params as [ number, @@ -574,9 +580,6 @@ class DrillFakeDatabase { number, number | null, string, - number, - number, - string, ]; const set = this.sets.get(id); if (set) { @@ -590,9 +593,6 @@ class DrillFakeDatabase { x_steps: xSteps, y_steps: ySteps, facing_degrees: facingDegrees, - label, - x_meters: xMeters, - y_meters: yMeters, }); } } diff --git a/packages/mobile/src/drill/analysis.ts b/packages/mobile/src/drill/analysis.ts index e249c476..33dc3f3f 100644 --- a/packages/mobile/src/drill/analysis.ts +++ b/packages/mobile/src/drill/analysis.ts @@ -66,7 +66,7 @@ function crossingCounts( /** * Derives transition metrics from drill-grid positions and incoming counts. - * Counts remain performer-facing metadata in v1; these convenience metrics do + * Counts remain performer-facing metadata; these convenience metrics do * not create a musical timeline or persisted step-size field. */ export function analyzeTransition( diff --git a/packages/mobile/src/drill/types.ts b/packages/mobile/src/drill/types.ts index 89a3ac28..9c101051 100644 --- a/packages/mobile/src/drill/types.ts +++ b/packages/mobile/src/drill/types.ts @@ -2,6 +2,7 @@ import type { DrillGridPoint, MeasureRange, SetKind, + FieldPresetId, } from "@eight2five/drill-schema"; /** @@ -13,7 +14,7 @@ export interface Drill { readonly name: string; readonly createdAt: number; readonly updatedAt: number; - readonly fieldPreset: "football-nfhs"; + readonly fieldPreset: FieldPresetId; } /** @@ -37,5 +38,5 @@ export interface DrillSet { readonly facingDegrees?: number; } -/** @deprecated Use DrillSet. Kept as a source-compatibility alias during v1 migration. */ +/** @deprecated Use DrillSet. Kept temporarily for source compatibility. */ export type DrillPage = DrillSet; diff --git a/packages/mobile/src/field/__tests__/anchor-position.test.ts b/packages/mobile/src/field/__tests__/anchor-position.test.ts index 0ce57f5e..0489f2d1 100644 --- a/packages/mobile/src/field/__tests__/anchor-position.test.ts +++ b/packages/mobile/src/field/__tests__/anchor-position.test.ts @@ -1,3 +1,4 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; import { ANCHOR_POSITION_REFERENCES, ANCHOR_POSITION_REFERENCE_POINTS, @@ -8,6 +9,7 @@ import { anchorFieldPositionFromStandard, anchorFieldPositionToStandard, convertAnchorPositionUnits, + createStandardFootballFieldTemplate, getAnchorPositionReferencePoint, metersToAnchorPositionUnits, drillGridPointToFieldPoint, @@ -16,6 +18,7 @@ import { } from "../index"; const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +const PRESETS = FIELD_PRESET_IDS; describe("shared anchor position domain", () => { test("defines exactly the standard references and their field points", () => { @@ -64,6 +67,25 @@ describe("shared anchor position domain", () => { }); }); + test.each(PRESETS)( + "%s derives hash reference points from the active field preset", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + expect( + getAnchorPositionReferencePoint("front-hash-center", preset), + ).toEqual({ + xMeters: 0, + yMeters: template.frontHashLine.coordinateMeters, + }); + expect( + getAnchorPositionReferencePoint("back-hash-center", preset), + ).toEqual({ + xMeters: 0, + yMeters: template.backHashLine.coordinateMeters, + }); + }, + ); + test("converts all supported units through one canonical meter path", () => { expect(ANCHOR_POSITION_UNITS).toEqual(["meters", "yards", "feet"]); const meters = anchorFieldPositionFromStandard({ @@ -170,6 +192,35 @@ describe("shared anchor position domain", () => { expect(expected.zMeters).toBe(2.4); }); + test.each(PRESETS)( + "%s projects marching anchor coordinates with that preset's hash convention", + (preset) => { + const position = anchorFieldPositionFromMarchingCoordinate( + { + side: { + side: "center", + yardLine: 50, + relation: "on", + offsetSteps: 0, + }, + frontBack: { + reference: "front-hash", + relation: "on", + offsetSteps: 0, + }, + }, + 2, + preset, + ); + const template = createStandardFootballFieldTemplate(preset); + expect(position.yMeters).toBeCloseTo( + template.frontHashLine.coordinateMeters, + 8, + ); + expect(position.zMeters).toBe(2); + }, + ); + test("parses drafts and rejects empty, non-finite, out-of-field, and excessive values", () => { expect( parseAnchorPositionDraft({ diff --git a/packages/mobile/src/field/__tests__/field-paths.test.ts b/packages/mobile/src/field/__tests__/field-paths.test.ts index e9b6fe86..3cbe82c8 100644 --- a/packages/mobile/src/field/__tests__/field-paths.test.ts +++ b/packages/mobile/src/field/__tests__/field-paths.test.ts @@ -1,8 +1,17 @@ +import { + drillGridToPhysicalPoint, + FIELD_PRESET_IDS, +} from "@eight2five/drill-schema"; + import { createFieldPaths } from "../render/create-field-paths"; -import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "../template"; -import { STANDARD_STEP_METERS, yardsToMeters } from "../units"; +import { + createStandardFootballFieldTemplate, + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +} from "../template"; +import { yardsToMeters } from "../units"; const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +const PRESETS = FIELD_PRESET_IDS; describe("aggregate field paths", () => { test("returns one immutable, memoized path set per template", () => { @@ -32,66 +41,154 @@ describe("aggregate field paths", () => { field: paths.fieldExtent, grid: paths.gridExtent, }); - expect(paths.stepGridSpacingMeters).toBe(STANDARD_STEP_METERS); - expect(paths.counts.stepGrid.spacingMeters).toBe(STANDARD_STEP_METERS); + expect(paths.stepGridSpacingSteps).toBe(1); + expect(paths.fourStepGridSpacingSteps).toBe(4); + expect(paths.counts.stepGrid.spacingSteps).toBe(1); }); - test("clips the fixed-spacing step grid at the exact padded extent", () => { - const paths = createFieldPaths(field); - const { minXMeters, maxXMeters, minYMeters, maxYMeters } = paths.gridExtent; - - expect(paths.stepGridPath).toContain( - segment(minXMeters, minYMeters, minXMeters, maxYMeters), - ); - expect(paths.stepGridPath).toContain( - segment(minXMeters, minYMeters, maxXMeters, minYMeters), - ); - expect(paths.stepGridPath.split(" M ").length).toBe( - paths.counts.stepGrid.verticalLineCount + - paths.counts.stepGrid.horizontalLineCount, - ); - - const verticalX = Array.from( - paths.stepGridPath.matchAll(/M (-?\d+(?:\.\d+)?) /g), - (match) => Number(match[1]), - ).slice(0, paths.counts.stepGrid.verticalLineCount); - for (let index = 1; index < verticalX.length; index += 1) { - expect(verticalX[index] - verticalX[index - 1]).toBeCloseTo( - STANDARD_STEP_METERS, - 6, + test.each(PRESETS)( + "%s projects the canonical 160 by 84 one-step marching grid onto the field", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + const paths = createFieldPaths(template); + const bounds = template.fieldDefinition.marchingGrid.bounds; + + expect(bounds).toEqual({ + minXSteps: -80, + maxXSteps: 80, + minYSteps: 0, + maxYSteps: 84, + }); + expect(paths.counts.stepGrid).toMatchObject({ + spacingSteps: 1, + verticalLineCount: 161, + horizontalLineCount: 85, + }); + expect(subpathCount(paths.stepGridPath)).toBe(246); + + const frontHash = gridPoint( + template, + 0, + gridReference(template, "front-hash"), ); - } - }); + const backHash = gridPoint( + template, + 0, + gridReference(template, "back-hash"), + ); + const backSideline = gridPoint(template, 0, 84); + expect(frontHash.yMeters).toBeCloseTo( + template.frontHashLine.coordinateMeters, + 8, + ); + expect(backHash.yMeters).toBeCloseTo( + template.backHashLine.coordinateMeters, + 8, + ); + expect(backSideline.yMeters).toBeCloseTo(template.bounds.maxYMeters, 8); - test("clips the five-yard grid to the field and includes both axes", () => { - const paths = createFieldPaths(field); - const coordinates = parseCoordinates(paths.fiveYardGridPath); - - for (const { xMeters, yMeters } of coordinates) { - expect(xMeters).toBeGreaterThanOrEqual(field.bounds.minXMeters); - expect(xMeters).toBeLessThanOrEqual(field.bounds.maxXMeters); - expect(yMeters).toBeGreaterThanOrEqual(field.bounds.minYMeters); - expect(yMeters).toBeLessThanOrEqual(field.bounds.maxYMeters); - } - - expect(paths.counts.fiveYardGrid).toMatchObject({ - spacingMeters: yardsToMeters(5), - verticalSubdivisionCount: 21, - horizontalSubdivisionCount: 11, - segmentCount: 32, - clippedToField: true, - }); - expect(paths.fiveYardGridPath).toContain( - segment( - field.bounds.minXMeters, - field.bounds.minYMeters, - field.bounds.maxXMeters, - field.bounds.minYMeters, - ), - ); + const frontHashSteps = gridReference(template, "front-hash"); + const backHashSteps = gridReference(template, "back-hash"); + if (Number.isInteger(frontHashSteps)) { + expect(paths.stepGridPath).toContain( + horizontalSegment( + template.bounds.minXMeters, + frontHash.yMeters, + template.bounds.maxXMeters, + ), + ); + } + if (Number.isInteger(backHashSteps)) { + expect(paths.stepGridPath).toContain( + horizontalSegment( + template.bounds.minXMeters, + backHash.yMeters, + template.bounds.maxXMeters, + ), + ); + } + }, + ); + + test("NFHS hashes are exactly 28, 28, 28 marching steps apart", () => { + const template = createStandardFootballFieldTemplate("football-nfhs"); + expect(gridReference(template, "front-sideline")).toBe(0); + expect(gridReference(template, "front-hash")).toBe(28); + expect(gridReference(template, "back-hash")).toBe(56); + expect(gridReference(template, "back-sideline")).toBe(84); }); - test("keeps football marks aggregate and exposes stable shape counts", () => { + test.each(PRESETS)( + "%s renders the blue overlay as four marching-step boxes", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + const paths = createFieldPaths(template); + const coordinates = parseCoordinates(paths.fourStepGridPath); + + for (const { xMeters, yMeters } of coordinates) { + expect(xMeters).toBeGreaterThanOrEqual( + template.bounds.minXMeters - 1e-6, + ); + expect(xMeters).toBeLessThanOrEqual(template.bounds.maxXMeters + 1e-6); + expect(yMeters).toBeGreaterThanOrEqual( + template.bounds.minYMeters - 1e-6, + ); + expect(yMeters).toBeLessThanOrEqual(template.bounds.maxYMeters + 1e-6); + } + + expect(paths.counts.fourStepGrid).toMatchObject({ + spacingSteps: 4, + verticalSubdivisionCount: 41, + horizontalSubdivisionCount: 22, + segmentCount: 63, + clippedToField: true, + }); + expect(subpathCount(paths.fourStepGridPath)).toBe(63); + expect(paths.fourStepGridPath).toContain( + horizontalSegment( + template.bounds.minXMeters, + gridPoint(template, 0, 84).yMeters, + template.bounds.maxXMeters, + ), + ); + }, + ); + + test.each(PRESETS)( + "%s optional one-step perimeter grid extends beyond every field edge", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + const paths = createFieldPaths(template); + const subpaths = parseSubpaths(paths.perimeterStepGridPath); + + expect(paths.counts.perimeterStepGrid.spacingSteps).toBe(1); + expect(paths.counts.perimeterStepGrid.clippedByFieldBackground).toBe( + true, + ); + expect( + subpaths.some( + ({ x1, x2 }) => x1 === x2 && x1 < template.bounds.minXMeters, + ), + ).toBe(true); + expect( + subpaths.some( + ({ x1, x2 }) => x1 === x2 && x1 > template.bounds.maxXMeters, + ), + ).toBe(true); + expect( + subpaths.some( + ({ y1, y2 }) => y1 === y2 && y1 < template.bounds.minYMeters, + ), + ).toBe(true); + expect( + subpaths.some( + ({ y1, y2 }) => y1 === y2 && y1 > template.bounds.maxYMeters, + ), + ).toBe(true); + }, + ); + + test("keeps physical football marks aggregate and exposes stable shape counts", () => { const paths = createFieldPaths(field); expect(subpathCount(paths.yardLinesPath)).toBe(19); @@ -109,13 +206,33 @@ describe("aggregate field paths", () => { }); }); -function segment( - startXMeters: number, - startYMeters: number, - endXMeters: number, - endYMeters: number, +function gridReference( + template: ReturnType, + id: string, +): number { + const reference = template.fieldDefinition.marchingGrid.referenceLines.find( + (line) => line.id === id, + ); + if (!reference) throw new Error(`Missing grid reference ${id}.`); + return reference.coordinateSteps; +} + +function gridPoint( + template: ReturnType, + xSteps: number, + ySteps: number, +) { + return drillGridToPhysicalPoint({ xSteps, ySteps }, template.fieldDefinition); +} + +function horizontalSegment( + minXMeters: number, + yMeters: number, + maxXMeters: number, ): string { - return `M ${format(startXMeters)} ${format(startYMeters)} L ${format(endXMeters)} ${format(endYMeters)}`; + return `M ${format(minXMeters)} ${format(yMeters)} L ${format( + maxXMeters, + )} ${format(yMeters)}`; } function parseCoordinates(path: string): { @@ -130,6 +247,25 @@ function parseCoordinates(path: string): { return coordinates; } +function parseSubpaths(path: string): { + x1: number; + y1: number; + x2: number; + y2: number; +}[] { + return Array.from( + path.matchAll( + /M (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) L (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?)/g, + ), + (match) => ({ + x1: Number(match[1]), + y1: Number(match[2]), + x2: Number(match[3]), + y2: Number(match[4]), + }), + ); +} + function subpathCount(path: string): number { return path.length === 0 ? 0 : (path.match(/M /g)?.length ?? 0); } diff --git a/packages/mobile/src/field/__tests__/guidance.test.ts b/packages/mobile/src/field/__tests__/guidance.test.ts index 914bfef5..c96e2598 100644 --- a/packages/mobile/src/field/__tests__/guidance.test.ts +++ b/packages/mobile/src/field/__tests__/guidance.test.ts @@ -1,14 +1,21 @@ -import { calculateFieldGuidance, standardStepsToMeters } from "../index"; +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; + +import { calculateFieldGuidance, drillGridPointToFieldPoint } from "../index"; + +const PRESETS = FIELD_PRESET_IDS; describe("field guidance", () => { - test("returns signed field-relative axis guidance and straight-line distance", () => { - const guidance = calculateFieldGuidance( - { xMeters: standardStepsToMeters(10), yMeters: standardStepsToMeters(5) }, - { - xMeters: standardStepsToMeters(2.5), - yMeters: standardStepsToMeters(8), - }, + test("returns signed field-relative axis guidance and straight-line grid distance", () => { + const current = drillGridPointToFieldPoint( + { xSteps: 10, ySteps: 5 }, + "football-nfhs", + ); + const target = drillGridPointToFieldPoint( + { xSteps: 2.5, ySteps: 8 }, + "football-nfhs", ); + const guidance = calculateFieldGuidance(current, target, "football-nfhs"); + expect(guidance.xDisplacementSteps).toBeCloseTo(-7.5); expect(guidance.yDisplacementSteps).toBeCloseTo(3); expect(guidance.distanceSteps).toBeCloseTo(Math.hypot(7.5, 3)); @@ -18,15 +25,34 @@ describe("field guidance", () => { test("uses front-sideline wording for negative Y and no phone heading", () => { const guidance = calculateFieldGuidance( - { xMeters: 0, yMeters: standardStepsToMeters(3) }, - { xMeters: 0, yMeters: 0 }, + drillGridPointToFieldPoint({ xSteps: 0, ySteps: 3 }), + drillGridPointToFieldPoint({ xSteps: 0, ySteps: 0 }), ); - expect(guidance.yDisplacementSteps).toBe(-3); + expect(guidance.yDisplacementSteps).toBeCloseTo(-3); expect(guidance.yLabel).toBe("3 steps toward the front sideline"); expect(guidance).not.toHaveProperty("heading"); expect(guidance).not.toHaveProperty("bearing"); }); + test.each(PRESETS)( + "%s reports exactly 84 marching steps sideline to sideline", + (preset) => { + const front = drillGridPointToFieldPoint( + { xSteps: 0, ySteps: 0 }, + preset, + ); + const back = drillGridPointToFieldPoint( + { xSteps: 0, ySteps: 84 }, + preset, + ); + const guidance = calculateFieldGuidance(front, back, preset); + + expect(guidance.xDisplacementSteps).toBeCloseTo(0); + expect(guidance.yDisplacementSteps).toBeCloseTo(84); + expect(guidance.distanceSteps).toBeCloseTo(84); + }, + ); + test("returns zero-axis guidance without inventing a direction", () => { const guidance = calculateFieldGuidance( { xMeters: 0, yMeters: 0 }, diff --git a/packages/mobile/src/field/__tests__/marching.test.ts b/packages/mobile/src/field/__tests__/marching.test.ts index 94a6ecf1..b0ad6633 100644 --- a/packages/mobile/src/field/__tests__/marching.test.ts +++ b/packages/mobile/src/field/__tests__/marching.test.ts @@ -1,3 +1,4 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, drillGridPointToFieldPoint, @@ -11,6 +12,7 @@ import { } from "../index"; const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +const PRESETS = FIELD_PRESET_IDS; function gridPoint(xSteps: number, ySteps: number) { return drillGridPointToFieldPoint({ xSteps, ySteps }); @@ -56,6 +58,37 @@ describe("marching coordinate conversion", () => { } }); + test.each(PRESETS)( + "%s round-trips the field-specific front hash through physical space", + (preset) => { + const fieldPoint = marchingCoordinateToFieldPoint( + { + side: { + side: "center", + yardLine: 50, + relation: "on", + offsetSteps: 0, + }, + frontBack: { + reference: "front-hash", + relation: "on", + offsetSteps: 0, + }, + }, + preset, + ); + const roundTrip = fieldPointToMarchingCoordinate(fieldPoint, preset); + expect(roundTrip.frontBack).toMatchObject({ + reference: "front-hash", + relation: "on", + offsetSteps: expect.closeTo(0, 8), + }); + expect(formatMarchingFrontBack(roundTrip.frontBack, preset)).toContain( + "FH", + ); + }, + ); + test("keeps canonical fractional values while formatting quarter steps", () => { const coordinate = fieldPointToMarchingCoordinate( gridPoint(-24 + 1.249999999, 28 + 2.500000001), diff --git a/packages/mobile/src/field/anchor-position.ts b/packages/mobile/src/field/anchor-position.ts index 837d113e..48a4ec41 100644 --- a/packages/mobile/src/field/anchor-position.ts +++ b/packages/mobile/src/field/anchor-position.ts @@ -1,8 +1,10 @@ +import type { FieldPresetId } from "@eight2five/drill-schema"; + import { marchingCoordinateToFieldPoint, type MarchingCoordinate, } from "./marching"; -import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "./template"; +import { createStandardFootballFieldTemplate } from "./template"; import type { AnchorFieldPosition, FieldPoint } from "./types"; import { feetToMeters, @@ -80,41 +82,56 @@ export interface ParsedAnchorPositionDraft { export const MAX_ANCHOR_HEIGHT_METERS = 100; -const template = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; -const bounds = template.bounds; -const centerXMeters = (bounds.minXMeters + bounds.maxXMeters) / 2; -const centerYMeters = (bounds.minYMeters + bounds.maxYMeters) / 2; -const point = (xMeters: number, yMeters: number): FieldPoint => ({ - xMeters, - yMeters, -}); +const REFERENCE_POINTS_CACHE = new Map< + FieldPresetId, + Readonly> +>(); -export const ANCHOR_POSITION_REFERENCE_POINTS: Readonly< - Record -> = Object.freeze({ - "center-field": point(centerXMeters, centerYMeters), - "center-front-sideline": point(centerXMeters, bounds.minYMeters), - "center-back-sideline": point(centerXMeters, bounds.maxYMeters), - "side-1-front-corner": point(bounds.minXMeters, bounds.minYMeters), - "side-1-back-corner": point(bounds.minXMeters, bounds.maxYMeters), - "side-2-front-corner": point(bounds.maxXMeters, bounds.minYMeters), - "side-2-back-corner": point(bounds.maxXMeters, bounds.maxYMeters), - "side-1-goal-line-center": point(bounds.minXMeters, centerYMeters), - "side-2-goal-line-center": point(bounds.maxXMeters, centerYMeters), - "front-hash-center": point( - centerXMeters, - template.frontHashLine.coordinateMeters, - ), - "back-hash-center": point( - centerXMeters, - template.backHashLine.coordinateMeters, - ), -}); +const point = (xMeters: number, yMeters: number): FieldPoint => + Object.freeze({ xMeters, yMeters }); + +function getAnchorPositionReferencePoints( + fieldPreset: FieldPresetId, +): Readonly> { + const cached = REFERENCE_POINTS_CACHE.get(fieldPreset); + if (cached) return cached; + + const template = createStandardFootballFieldTemplate(fieldPreset); + const bounds = template.bounds; + const centerXMeters = (bounds.minXMeters + bounds.maxXMeters) / 2; + const centerYMeters = (bounds.minYMeters + bounds.maxYMeters) / 2; + const references = Object.freeze({ + "center-field": point(centerXMeters, centerYMeters), + "center-front-sideline": point(centerXMeters, bounds.minYMeters), + "center-back-sideline": point(centerXMeters, bounds.maxYMeters), + "side-1-front-corner": point(bounds.minXMeters, bounds.minYMeters), + "side-1-back-corner": point(bounds.minXMeters, bounds.maxYMeters), + "side-2-front-corner": point(bounds.maxXMeters, bounds.minYMeters), + "side-2-back-corner": point(bounds.maxXMeters, bounds.maxYMeters), + "side-1-goal-line-center": point(bounds.minXMeters, centerYMeters), + "side-2-goal-line-center": point(bounds.maxXMeters, centerYMeters), + "front-hash-center": point( + centerXMeters, + template.frontHashLine.coordinateMeters, + ), + "back-hash-center": point( + centerXMeters, + template.backHashLine.coordinateMeters, + ), + } satisfies Record); + REFERENCE_POINTS_CACHE.set(fieldPreset, references); + return references; +} + +/** NFHS compatibility snapshot for callers that consume the constant directly. */ +export const ANCHOR_POSITION_REFERENCE_POINTS = + getAnchorPositionReferencePoints("football-nfhs"); export function getAnchorPositionReferencePoint( reference: AnchorPositionReference, + fieldPreset: FieldPresetId = "football-nfhs", ): FieldPoint { - return ANCHOR_POSITION_REFERENCE_POINTS[reference]; + return getAnchorPositionReferencePoints(fieldPreset)[reference]; } export function anchorPositionUnitsToMeters( @@ -150,8 +167,12 @@ export function convertAnchorPositionUnits( export function anchorFieldPositionFromStandard( input: StandardAnchorPositionInput, + fieldPreset: FieldPresetId = "football-nfhs", ): AnchorFieldPosition { - const reference = getAnchorPositionReferencePoint(input.reference); + const reference = getAnchorPositionReferencePoint( + input.reference, + fieldPreset, + ); const position = { xMeters: reference.xMeters + @@ -161,7 +182,7 @@ export function anchorFieldPositionFromStandard( anchorPositionUnitsToMeters(input.frontToBackOffset, input.unit), zMeters: anchorPositionUnitsToMeters(input.height, input.unit), }; - assertValidAnchorFieldPosition(position); + assertValidAnchorFieldPosition(position, fieldPreset); return position; } @@ -169,9 +190,10 @@ export function anchorFieldPositionToStandard( position: AnchorFieldPosition, reference: AnchorPositionReference, unit: AnchorPositionUnit, + fieldPreset: FieldPresetId = "football-nfhs", ): StandardAnchorPositionInput { - assertValidAnchorFieldPosition(position); - const origin = getAnchorPositionReferencePoint(reference); + assertValidAnchorFieldPosition(position, fieldPreset); + const origin = getAnchorPositionReferencePoint(reference, fieldPreset); return { reference, unit, @@ -190,17 +212,19 @@ export function anchorFieldPositionToStandard( export function anchorFieldPositionFromMarchingCoordinate( coordinate: MarchingCoordinate, heightMeters: number, + fieldPreset: FieldPresetId = "football-nfhs", ): AnchorFieldPosition { const position = { - ...marchingCoordinateToFieldPoint(coordinate), + ...marchingCoordinateToFieldPoint(coordinate, fieldPreset), zMeters: heightMeters, }; - assertValidAnchorFieldPosition(position); + assertValidAnchorFieldPosition(position, fieldPreset); return position; } export function parseAnchorPositionDraft( draft: StandardAnchorPositionDraft, + fieldPreset: FieldPresetId = "football-nfhs", ): ParsedAnchorPositionDraft { const errors: AnchorPositionDraftErrors = {}; const sideToSideOffset = parseFiniteDraftNumber( @@ -235,13 +259,16 @@ export function parseAnchorPositionDraft( try { return { errors, - value: anchorFieldPositionFromStandard({ - reference: draft.reference, - unit: draft.unit, - sideToSideOffset, - frontToBackOffset, - height, - }), + value: anchorFieldPositionFromStandard( + { + reference: draft.reference, + unit: draft.unit, + sideToSideOffset, + frontToBackOffset, + height, + }, + fieldPreset, + ), }; } catch (cause) { return { @@ -255,6 +282,7 @@ export function parseAnchorPositionDraft( export function validateAnchorFieldPosition( position: unknown, + fieldPreset: FieldPresetId = "football-nfhs", ): AnchorPositionDraftErrors { if (!position || typeof position !== "object") { return { position: "Anchor field position is required." }; @@ -267,6 +295,7 @@ export function validateAnchorFieldPosition( ) { return { position: "Anchor coordinates must be finite." }; } + const bounds = createStandardFootballFieldTemplate(fieldPreset).bounds; if ( value.xMeters! < bounds.minXMeters || value.xMeters! > bounds.maxXMeters || @@ -290,8 +319,9 @@ export function validateAnchorFieldPosition( export function assertValidAnchorFieldPosition( position: unknown, + fieldPreset: FieldPresetId = "football-nfhs", ): asserts position is AnchorFieldPosition { - const message = validateAnchorFieldPosition(position).position; + const message = validateAnchorFieldPosition(position, fieldPreset).position; if (message) throw new RangeError(message); } diff --git a/packages/mobile/src/field/camera/field-camera-policy.ts b/packages/mobile/src/field/camera/field-camera-policy.ts index 51fa6837..54e300e9 100644 --- a/packages/mobile/src/field/camera/field-camera-policy.ts +++ b/packages/mobile/src/field/camera/field-camera-policy.ts @@ -1,6 +1,6 @@ import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, - type StandardHighSchoolFieldTemplate, + type StandardFootballFieldTemplate, } from "../template"; import { yardsToMeters } from "../units"; import type { @@ -16,7 +16,7 @@ export const FIELD_ZOOM_OUT_BREATHING_ROOM = 1.2; export const FIELD_INITIAL_BREATHING_ROOM = 1.06; export function getFieldGridBounds( - template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + template: StandardFootballFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, ): FieldCameraBounds { const padding = yardsToMeters(FIELD_GRID_PERIMETER_YARDS); return { @@ -28,7 +28,7 @@ export function getFieldGridBounds( } export function getFieldCameraBounds( - template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + template: StandardFootballFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, ): FieldCameraBounds { const gridBounds = getFieldGridBounds(template); const margin = yardsToMeters(FIELD_CAMERA_BLANK_MARGIN_YARDS); @@ -66,7 +66,7 @@ export function getFieldMaximumMetersPerPixel( export function getInitialFieldViewport( size: FieldViewportSize, - template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + template: StandardFootballFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, ): FieldViewport { const bounds = getFieldGridBounds(template); return { diff --git a/packages/mobile/src/field/guidance.ts b/packages/mobile/src/field/guidance.ts index f1b3abb8..ae469020 100644 --- a/packages/mobile/src/field/guidance.ts +++ b/packages/mobile/src/field/guidance.ts @@ -1,14 +1,14 @@ import { assertFiniteFieldPoint, type FieldPoint } from "./types"; import { - fieldPointDisplacementInStandardSteps, - metersToStandardSteps, -} from "./units"; -import { formatMarchingSteps } from "./marching"; + fieldPointToDrillGridPoint, + formatMarchingSteps, + type MarchingFieldInput, +} from "./marching"; export interface FieldGuidance { - /** Straight-line horizontal distance, in standard 8-to-5 steps. */ + /** Straight-line distance in the active field's marching-grid coordinates. */ readonly distanceSteps: number; - /** Signed target-minus-current displacement along canonical X/Y axes. */ + /** Signed target-minus-current displacement along canonical grid X/Y axes. */ readonly xDisplacementSteps: number; readonly yDisplacementSteps: number; readonly xLabel: string; @@ -28,24 +28,24 @@ function formatGuidanceAxis( } /** - * Produces field-relative guidance only. It deliberately does not use device - * heading, phone orientation, compass data, or any other view-dependent input. + * Produces field-relative guidance in the active marching coordinate system. + * Physical meters are projected through the field definition first so an NFHS + * sideline-to-sideline move is exactly 84 grid steps rather than 85 1/3 literal + * 22.5-inch intervals. */ export function calculateFieldGuidance( current: FieldPoint, target: FieldPoint, + field?: MarchingFieldInput, ): FieldGuidance { assertFiniteFieldPoint(current, "Current point"); assertFiniteFieldPoint(target, "Target point"); - const { xSteps, ySteps } = fieldPointDisplacementInStandardSteps( - current, - target, - ); - const xMeters = target.xMeters - current.xMeters; - const yMeters = target.yMeters - current.yMeters; - const distanceSteps = metersToStandardSteps(Math.hypot(xMeters, yMeters)); + const currentGrid = fieldPointToDrillGridPoint(current, field); + const targetGrid = fieldPointToDrillGridPoint(target, field); + const xSteps = targetGrid.xSteps - currentGrid.xSteps; + const ySteps = targetGrid.ySteps - currentGrid.ySteps; return Object.freeze({ - distanceSteps, + distanceSteps: Math.hypot(xSteps, ySteps), xDisplacementSteps: xSteps, yDisplacementSteps: ySteps, xLabel: formatGuidanceAxis(xSteps, "Side 1", "Side 2"), diff --git a/packages/mobile/src/field/marching.ts b/packages/mobile/src/field/marching.ts index 6d1918cd..f8b6facf 100644 --- a/packages/mobile/src/field/marching.ts +++ b/packages/mobile/src/field/marching.ts @@ -1,8 +1,13 @@ import { drillGridToPhysicalPoint, getFieldPreset, + getGridReference, physicalPointToDrillGrid, + resolveFieldDefinition, type DrillGridPoint, + type FieldDefinition, + type FieldPresetId, + type ResolvedFieldDefinition, } from "@eight2five/drill-schema"; import { @@ -10,14 +15,26 @@ import { type FieldLateralReference, type FieldPoint, } from "./types"; -import { - STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, - type StandardHighSchoolFieldTemplate, -} from "./template"; +import type { StandardFootballFieldTemplate } from "./template"; const EPSILON = 1e-9; const NFHS_FIELD = getFieldPreset("football-nfhs"); +export type MarchingFieldInput = + | FieldPresetId + | FieldDefinition + | ResolvedFieldDefinition + | StandardFootballFieldTemplate; + +function resolveMarchingField( + field: MarchingFieldInput = NFHS_FIELD, +): ResolvedFieldDefinition { + if (typeof field === "string") return getFieldPreset(field); + if ("fieldDefinition" in field) return field.fieldDefinition; + if ("type" in field) return resolveFieldDefinition(field); + return field; +} + export type MarchingSideReference = 1 | 2 | "center"; export type MarchingSideRelation = "on" | "inside" | "outside"; export type MarchingFrontBackRelation = "on" | "in-front-of" | "behind"; @@ -107,13 +124,23 @@ interface LateralReference { readonly ySteps: number; } -const LATERAL_REFERENCES: readonly LateralReference[] = Object.freeze([ - { reference: "front-sideline", ySteps: 0 }, - { reference: "front-hash", ySteps: 28 }, - { reference: "back-hash", ySteps: 56 }, - { reference: "back-sideline", ySteps: 84 }, +const LATERAL_REFERENCE_IDS: readonly FieldLateralReference[] = Object.freeze([ + "front-sideline", + "front-hash", + "back-hash", + "back-sideline", ]); +function lateralReferences( + field: ResolvedFieldDefinition, +): readonly LateralReference[] { + return LATERAL_REFERENCE_IDS.map((reference) => { + const line = getGridReference(field, reference); + if (!line) throw new RangeError(`Field is missing ${reference}.`); + return Object.freeze({ reference, ySteps: line.coordinateSteps }); + }); +} + /** Deterministic nearest-reference selection avoids display flicker at ties. */ function nearestReference( value: number, @@ -181,12 +208,20 @@ function makeSideCoordinate(xSteps: number): MarchingSideCoordinate { }); } -function makeFrontBackCoordinate(ySteps: number): MarchingFrontBackCoordinate { - const references = LATERAL_REFERENCES.map((reference) => ({ +function makeFrontBackCoordinate( + ySteps: number, + field: ResolvedFieldDefinition, +): MarchingFrontBackCoordinate { + const references = lateralReferences(field).map((reference) => ({ ...reference, coordinate: reference.ySteps, })); - const nearest = nearestReference(ySteps, references, 42); + const bounds = field.marchingGrid.bounds; + const nearest = nearestReference( + ySteps, + references, + (bounds.minYSteps + bounds.maxYSteps) / 2, + ); const offsetYSteps = ySteps - nearest.ySteps; return Object.freeze({ reference: nearest.reference, @@ -197,12 +232,20 @@ function makeFrontBackCoordinate(ySteps: number): MarchingFrontBackCoordinate { function getGridOutOfBounds( point: DrillGridPoint, + field: ResolvedFieldDefinition, ): readonly ("goal-to-goal" | "front-back")[] | undefined { const outOfBounds: ("goal-to-goal" | "front-back")[] = []; - if (point.xSteps < -80 - EPSILON || point.xSteps > 80 + EPSILON) { + const bounds = field.marchingGrid.bounds; + if ( + point.xSteps < bounds.minXSteps - EPSILON || + point.xSteps > bounds.maxXSteps + EPSILON + ) { outOfBounds.push("goal-to-goal"); } - if (point.ySteps < -EPSILON || point.ySteps > 84 + EPSILON) { + if ( + point.ySteps < bounds.minYSteps - EPSILON || + point.ySteps > bounds.maxYSteps + EPSILON + ) { outOfBounds.push("front-back"); } return outOfBounds.length > 0 ? Object.freeze(outOfBounds) : undefined; @@ -210,25 +253,29 @@ function getGridOutOfBounds( export function drillGridPointToMarchingCoordinate( point: DrillGridPoint, + fieldInput: MarchingFieldInput = NFHS_FIELD, ): MarchingCoordinate { if (!Number.isFinite(point.xSteps) || !Number.isFinite(point.ySteps)) { throw new RangeError("Drill grid coordinates must be finite."); } - const outOfBounds = getGridOutOfBounds(point); + const field = resolveMarchingField(fieldInput); + const outOfBounds = getGridOutOfBounds(point, field); return Object.freeze({ side: makeSideCoordinate(point.xSteps), - frontBack: makeFrontBackCoordinate(point.ySteps), + frontBack: makeFrontBackCoordinate(point.ySteps, field), ...(outOfBounds ? { outOfBounds } : {}), }); } export function fieldPointToMarchingCoordinate( point: FieldPoint, - _template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + fieldInput: MarchingFieldInput = NFHS_FIELD, ): MarchingCoordinate { assertFiniteFieldPoint(point); + const field = resolveMarchingField(fieldInput); return drillGridPointToMarchingCoordinate( - physicalPointToDrillGrid(point, NFHS_FIELD), + physicalPointToDrillGrid(point, field), + field, ); } @@ -303,18 +350,15 @@ function sideCoordinateToXSteps(coordinate: MarchingSideCoordinate): number { function frontBackCoordinateToYSteps( coordinate: MarchingFrontBackCoordinate, + field: ResolvedFieldDefinition, ): number { assertOffset(coordinate.offsetSteps, "Marching front/back offsetSteps"); if (coordinate.relation === "on" && coordinate.offsetSteps > EPSILON) { throw new RangeError('An "on" marching coordinate must have zero offset.'); } - const yByReference: Record = { - "front-sideline": 0, - "front-hash": 28, - "back-hash": 56, - "back-sideline": 84, - }; - const lineY = yByReference[coordinate.reference]; + const line = getGridReference(field, coordinate.reference); + if (!line) throw new RangeError(`Field is missing ${coordinate.reference}.`); + const lineY = line.coordinateSteps; if (coordinate.relation === "on") return lineY; if (coordinate.relation === "in-front-of") { return lineY - coordinate.offsetSteps; @@ -329,25 +373,28 @@ function frontBackCoordinateToYSteps( export function marchingCoordinateToDrillGridPoint( coordinate: MarchingCoordinate, + fieldInput: MarchingFieldInput = NFHS_FIELD, ): DrillGridPoint { if (!coordinate || !coordinate.side || !coordinate.frontBack) { throw new TypeError( "A marching coordinate requires side and frontBack values.", ); } + const field = resolveMarchingField(fieldInput); return Object.freeze({ xSteps: sideCoordinateToXSteps(coordinate.side), - ySteps: frontBackCoordinateToYSteps(coordinate.frontBack), + ySteps: frontBackCoordinateToYSteps(coordinate.frontBack, field), }); } export function marchingCoordinateToFieldPoint( coordinate: MarchingCoordinate, - _template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + fieldInput: MarchingFieldInput = NFHS_FIELD, ): FieldPoint { + const field = resolveMarchingField(fieldInput); const physical = drillGridToPhysicalPoint( - marchingCoordinateToDrillGridPoint(coordinate), - NFHS_FIELD, + marchingCoordinateToDrillGridPoint(coordinate, field), + field, ); const point = { xMeters: physical.xMeters, @@ -357,17 +404,26 @@ export function marchingCoordinateToFieldPoint( return Object.freeze(point); } -export function drillGridPointToFieldPoint(point: DrillGridPoint): FieldPoint { - const physical = drillGridToPhysicalPoint(point, NFHS_FIELD); +export function drillGridPointToFieldPoint( + point: DrillGridPoint, + fieldInput: MarchingFieldInput = NFHS_FIELD, +): FieldPoint { + const physical = drillGridToPhysicalPoint( + point, + resolveMarchingField(fieldInput), + ); return Object.freeze({ xMeters: physical.xMeters, yMeters: physical.yMeters, }); } -export function fieldPointToDrillGridPoint(point: FieldPoint): DrillGridPoint { +export function fieldPointToDrillGridPoint( + point: FieldPoint, + fieldInput: MarchingFieldInput = NFHS_FIELD, +): DrillGridPoint { assertFiniteFieldPoint(point); - return physicalPointToDrillGrid(point, NFHS_FIELD); + return physicalPointToDrillGrid(point, resolveMarchingField(fieldInput)); } export const fieldPointToMarching = fieldPointToMarchingCoordinate; @@ -387,14 +443,36 @@ function formatSideCoordinate(coordinate: MarchingSideCoordinate): string { return `Side ${coordinate.side}: ${steps} ${coordinate.relation} ${line}`; } -function lateralReferenceText(reference: FieldLateralReference): string { +function hashReferencePrefix(field: ResolvedFieldDefinition): string { + switch (field.id) { + case "football-nfhs": + return "HS"; + case "football-ncaa": + return "NCAA"; + case "football-texas-uil": + return "UIL"; + case "football-nfl": + return "NFL"; + case "custom": + return ""; + } +} + +function lateralReferenceText( + reference: FieldLateralReference, + field: ResolvedFieldDefinition, +): string { switch (reference) { case "front-sideline": return "Front Sideline"; - case "front-hash": - return "HS FH"; - case "back-hash": - return "HS BH"; + case "front-hash": { + const prefix = hashReferencePrefix(field); + return prefix ? `${prefix} FH` : "Front Hash"; + } + case "back-hash": { + const prefix = hashReferencePrefix(field); + return prefix ? `${prefix} BH` : "Back Hash"; + } case "back-sideline": return "Back Sideline"; } @@ -402,8 +480,9 @@ function lateralReferenceText(reference: FieldLateralReference): string { function formatFrontBackCoordinate( coordinate: MarchingFrontBackCoordinate, + field: ResolvedFieldDefinition, ): string { - const reference = lateralReferenceText(coordinate.reference); + const reference = lateralReferenceText(coordinate.reference, field); if (coordinate.relation === "on") return `On ${reference}`; return `${stepWord(coordinate.offsetSteps)} ${ coordinate.relation === "behind" ? "behind" : "in front of" @@ -418,27 +497,32 @@ export const formatMarchingSideCoordinate = formatMarchingSide; export function formatMarchingFrontBack( coordinate: MarchingFrontBackCoordinate, + fieldInput: MarchingFieldInput = NFHS_FIELD, ): string { - return formatFrontBackCoordinate(coordinate); + return formatFrontBackCoordinate( + coordinate, + resolveMarchingField(fieldInput), + ); } export const formatMarchingFrontBackCoordinate = formatMarchingFrontBack; export function formatMarchingCoordinate( coordinateOrPoint: MarchingCoordinate | FieldPoint | DrillGridPoint, - template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + fieldInput: MarchingFieldInput = NFHS_FIELD, ): string { + const field = resolveMarchingField(fieldInput); let coordinate: MarchingCoordinate; if ("side" in coordinateOrPoint) { coordinate = coordinateOrPoint; } else if ("xSteps" in coordinateOrPoint) { - coordinate = drillGridPointToMarchingCoordinate(coordinateOrPoint); + coordinate = drillGridPointToMarchingCoordinate(coordinateOrPoint, field); } else { - coordinate = fieldPointToMarchingCoordinate(coordinateOrPoint, template); + coordinate = fieldPointToMarchingCoordinate(coordinateOrPoint, field); } const parts = [ formatSideCoordinate(coordinate.side), - formatFrontBackCoordinate(coordinate.frontBack), + formatFrontBackCoordinate(coordinate.frontBack, field), ]; const formatted = parts.join("; "); return coordinate.outOfBounds?.length diff --git a/packages/mobile/src/field/render/create-field-paths.ts b/packages/mobile/src/field/render/create-field-paths.ts index 87121a4e..104db630 100644 --- a/packages/mobile/src/field/render/create-field-paths.ts +++ b/packages/mobile/src/field/render/create-field-paths.ts @@ -1,13 +1,20 @@ -import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "../template"; -import type { StandardHighSchoolFieldTemplate } from "../template"; -import { feetToMeters, STANDARD_STEP_METERS, yardsToMeters } from "../units"; +import { + drillGridToPhysicalPoint, + physicalPointToDrillGrid, +} from "@eight2five/drill-schema"; + +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + type StandardFootballFieldTemplate, +} from "../template"; +import { feetToMeters, yardsToMeters } from "../units"; const GRID_PADDING_YARDS = 10; -const FIVE_YARD_GRID_SPACING_METERS = yardsToMeters(5); const HASH_MARK_SPACING_METERS = yardsToMeters(1); const HASH_MARK_LENGTH_METERS = feetToMeters(2); const PATH_NUMBER_PRECISION = 1_000_000; const COORDINATE_EPSILON = 1e-9; +const FOUR_STEP_INTERVAL = 4; export interface FieldPathExtent { readonly minXMeters: number; @@ -16,14 +23,18 @@ export interface FieldPathExtent { readonly maxYMeters: number; } -export interface StepGridPathMetadata { - readonly spacingMeters: typeof STANDARD_STEP_METERS; +export interface MarchingGridPathMetadata { + readonly spacingSteps: 1; readonly verticalLineCount: number; readonly horizontalLineCount: number; } -export interface FiveYardGridPathMetadata { - readonly spacingMeters: number; +export interface PerimeterMarchingGridPathMetadata extends MarchingGridPathMetadata { + readonly clippedByFieldBackground: true; +} + +export interface FourStepGridPathMetadata { + readonly spacingSteps: 4; readonly verticalSubdivisionCount: number; readonly horizontalSubdivisionCount: number; readonly segmentCount: number; @@ -47,53 +58,52 @@ export interface BoundaryPathMetadata { } export interface FieldPathCounts { - readonly stepGrid: StepGridPathMetadata; - readonly fiveYardGrid: FiveYardGridPathMetadata; + readonly stepGrid: MarchingGridPathMetadata; + readonly perimeterStepGrid: PerimeterMarchingGridPathMetadata; + readonly fourStepGrid: FourStepGridPathMetadata; readonly yardLines: YardLinesPathMetadata; readonly hashMarks: HashMarksPathMetadata; readonly boundary: BoundaryPathMetadata; } -/** - * The immutable, world-space SVG geometry consumed by field renderers. - * - * Each path is a single aggregate string rather than a collection of line - * components. Coordinates stay in the field's canonical meter coordinate - * system: X runs from Side 1 to Side 2 and Y runs from the front sideline to - * the back sideline. - */ +/** Immutable world-space geometry projected from the active marching field. */ export interface FieldPaths { + /** One marching-grid step, clipped to the physical field. */ readonly stepGridPath: string; - readonly fiveYardGridPath: string; + /** One marching-grid step across the 10-yard camera perimeter. */ + readonly perimeterStepGridPath: string; + /** Four marching-grid steps, clipped to the physical field. */ + readonly fourStepGridPath: string; readonly yardLinesPath: string; readonly hashMarksPath: string; readonly boundaryPath: string; readonly fieldExtent: FieldPathExtent; readonly gridExtent: FieldPathExtent; - readonly stepGridSpacingMeters: typeof STANDARD_STEP_METERS; + readonly stepGridSpacingSteps: 1; + readonly fourStepGridSpacingSteps: 4; readonly extents: { readonly field: FieldPathExtent; readonly grid: FieldPathExtent; }; readonly counts: FieldPathCounts; - /** Short aliases keep the path set convenient for drawing callers. */ readonly stepGrid: string; - readonly fiveYardGrid: string; + readonly perimeterStepGrid: string; + readonly fourStepGrid: string; readonly yardLines: string; readonly hashMarks: string; readonly boundary: string; } -const PATH_CACHE = new WeakMap(); +const PATH_CACHE = new WeakMap(); /** - * Builds all static field geometry in one pass and memoizes it by template. - * The standard template is deeply immutable, so identity-based memoization is - * sufficient and avoids rebuilding hundreds of path segments on every render. + * Project the active drill schema's marching grid onto the exact physical + * football geometry. Grid steps are abstract drill coordinates; they are not + * assumed to equal 22.5 physical inches on both axes. */ export function createFieldPaths( - template: StandardHighSchoolFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + template: StandardFootballFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, ): FieldPaths { const cached = PATH_CACHE.get(template); if (cached) return cached; @@ -112,45 +122,54 @@ export function createFieldPaths( maxYMeters: fieldExtent.maxYMeters + gridPaddingMeters, }); - const stepGridXCoordinates = coordinatesAtInterval( - gridExtent.minXMeters, - gridExtent.maxXMeters, - STANDARD_STEP_METERS, + const marchingBounds = template.fieldDefinition.marchingGrid.bounds; + const stepGridXSteps = integerCoordinates( + marchingBounds.minXSteps, + marchingBounds.maxXSteps, ); - const stepGridYCoordinates = coordinatesAtInterval( - gridExtent.minYMeters, - gridExtent.maxYMeters, - STANDARD_STEP_METERS, + const stepGridYSteps = integerCoordinates( + marchingBounds.minYSteps, + marchingBounds.maxYSteps, + ); + const stepGridPath = gridPathFromSteps( + template, + stepGridXSteps, + stepGridYSteps, + fieldExtent, ); - const stepGridPath = [ - ...stepGridXCoordinates.map((xMeters) => - verticalSegment(xMeters, gridExtent.minYMeters, gridExtent.maxYMeters), - ), - ...stepGridYCoordinates.map((yMeters) => - horizontalSegment(gridExtent.minXMeters, yMeters, gridExtent.maxXMeters), - ), - ].join(" "); - const fiveYardXCoordinates = template.allFiveYardLines.map( - (line) => line.coordinateMeters, + const perimeterGridBounds = physicalExtentToGridBounds(template, gridExtent); + const perimeterXSteps = integerCoordinates( + Math.floor(perimeterGridBounds.minXSteps), + Math.ceil(perimeterGridBounds.maxXSteps), ); - const fiveYardYCoordinates = coordinatesAtInterval( - fieldExtent.minYMeters, - fieldExtent.maxYMeters, - FIVE_YARD_GRID_SPACING_METERS, + const perimeterYSteps = integerCoordinates( + Math.floor(perimeterGridBounds.minYSteps), + Math.ceil(perimeterGridBounds.maxYSteps), + ); + const perimeterStepGridPath = gridPathFromSteps( + template, + perimeterXSteps, + perimeterYSteps, + gridExtent, + ); + + const fourStepXSteps = stepIntervalCoordinates( + marchingBounds.minXSteps, + marchingBounds.maxXSteps, + FOUR_STEP_INTERVAL, + ); + const fourStepYSteps = stepIntervalCoordinates( + marchingBounds.minYSteps, + marchingBounds.maxYSteps, + FOUR_STEP_INTERVAL, + ); + const fourStepGridPath = gridPathFromSteps( + template, + fourStepXSteps, + fourStepYSteps, + fieldExtent, ); - const fiveYardGridPath = [ - ...fiveYardXCoordinates.map((xMeters) => - verticalSegment(xMeters, fieldExtent.minYMeters, fieldExtent.maxYMeters), - ), - ...fiveYardYCoordinates.map((yMeters) => - horizontalSegment( - fieldExtent.minXMeters, - yMeters, - fieldExtent.maxXMeters, - ), - ), - ].join(" "); const yardLinesPath = template.yardLines .map((line) => @@ -166,7 +185,7 @@ export function createFieldPaths( template.frontHashLine.coordinateMeters, template.backHashLine.coordinateMeters, ] as const; - const hashMarks = [] as string[]; + const hashMarks: string[] = []; const ticksPerRow = Math.max(0, Math.ceil(template.goalToGoalYards) - 1); for (const yMeters of hashYCoordinates) { for (let yard = 1; yard < template.goalToGoalYards; yard += 1) { @@ -186,15 +205,21 @@ export function createFieldPaths( const extents = Object.freeze({ field: fieldExtent, grid: gridExtent }); const counts: FieldPathCounts = Object.freeze({ stepGrid: Object.freeze({ - spacingMeters: STANDARD_STEP_METERS, - verticalLineCount: stepGridXCoordinates.length, - horizontalLineCount: stepGridYCoordinates.length, + spacingSteps: 1, + verticalLineCount: stepGridXSteps.length, + horizontalLineCount: stepGridYSteps.length, + }), + perimeterStepGrid: Object.freeze({ + spacingSteps: 1, + verticalLineCount: perimeterXSteps.length, + horizontalLineCount: perimeterYSteps.length, + clippedByFieldBackground: true, }), - fiveYardGrid: Object.freeze({ - spacingMeters: FIVE_YARD_GRID_SPACING_METERS, - verticalSubdivisionCount: fiveYardXCoordinates.length, - horizontalSubdivisionCount: fiveYardYCoordinates.length, - segmentCount: fiveYardXCoordinates.length + fiveYardYCoordinates.length, + fourStepGrid: Object.freeze({ + spacingSteps: 4, + verticalSubdivisionCount: fourStepXSteps.length, + horizontalSubdivisionCount: fourStepYSteps.length, + segmentCount: fourStepXSteps.length + fourStepYSteps.length, clippedToField: true, }), yardLines: Object.freeze({ lineCount: template.yardLines.length }), @@ -210,17 +235,20 @@ export function createFieldPaths( const paths: FieldPaths = Object.freeze({ stepGridPath, - fiveYardGridPath, + perimeterStepGridPath, + fourStepGridPath, yardLinesPath, hashMarksPath, boundaryPath, fieldExtent, gridExtent, - stepGridSpacingMeters: STANDARD_STEP_METERS, + stepGridSpacingSteps: 1, + fourStepGridSpacingSteps: 4, extents, counts, stepGrid: stepGridPath, - fiveYardGrid: fiveYardGridPath, + perimeterStepGrid: perimeterStepGridPath, + fourStepGrid: fourStepGridPath, yardLines: yardLinesPath, hashMarks: hashMarksPath, boundary: boundaryPath, @@ -229,29 +257,119 @@ export function createFieldPaths( return paths; } -/** Alias for callers that describe the operation as building geometry. */ export const buildFieldPaths = createFieldPaths; -function freezeExtent(extent: FieldPathExtent): FieldPathExtent { - return Object.freeze(extent); +function gridPathFromSteps( + template: StandardFootballFieldTemplate, + xSteps: readonly number[], + ySteps: readonly number[], + extent: FieldPathExtent, +): string { + return [ + ...xSteps.map((step) => + verticalSegment( + projectXStep(template, step), + extent.minYMeters, + extent.maxYMeters, + ), + ), + ...ySteps.map((step) => + horizontalSegment( + extent.minXMeters, + projectYStep(template, step), + extent.maxXMeters, + ), + ), + ].join(" "); } -function coordinatesAtInterval( +function projectXStep( + template: StandardFootballFieldTemplate, + xSteps: number, +): number { + return drillGridToPhysicalPoint( + { + xSteps, + ySteps: template.fieldDefinition.marchingGrid.bounds.minYSteps, + }, + template.fieldDefinition, + ).xMeters; +} + +function projectYStep( + template: StandardFootballFieldTemplate, + ySteps: number, +): number { + return drillGridToPhysicalPoint( + { + xSteps: 0, + ySteps, + }, + template.fieldDefinition, + ).yMeters; +} + +function physicalExtentToGridBounds( + template: StandardFootballFieldTemplate, + extent: FieldPathExtent, +) { + const field = template.fieldDefinition; + const xMin = physicalPointToDrillGrid( + { xMeters: extent.minXMeters, yMeters: template.bounds.minYMeters }, + field, + ).xSteps; + const xMax = physicalPointToDrillGrid( + { xMeters: extent.maxXMeters, yMeters: template.bounds.minYMeters }, + field, + ).xSteps; + const yMin = physicalPointToDrillGrid( + { xMeters: 0, yMeters: extent.minYMeters }, + field, + ).ySteps; + const yMax = physicalPointToDrillGrid( + { xMeters: 0, yMeters: extent.maxYMeters }, + field, + ).ySteps; + return { minXSteps: xMin, maxXSteps: xMax, minYSteps: yMin, maxYSteps: yMax }; +} + +function integerCoordinates( + minimum: number, + maximum: number, +): readonly number[] { + const start = Math.ceil(minimum - COORDINATE_EPSILON); + const end = Math.floor(maximum + COORDINATE_EPSILON); + const coordinates: number[] = []; + for (let value = start; value <= end; value += 1) coordinates.push(value); + return Object.freeze(coordinates); +} + +function stepIntervalCoordinates( minimum: number, maximum: number, interval: number, ): readonly number[] { const coordinates: number[] = []; - const intervalCount = Math.floor( - (maximum - minimum) / interval + COORDINATE_EPSILON, - ); - for (let index = 0; index <= intervalCount; index += 1) { - coordinates.push(minimum + index * interval); + for ( + let value = minimum; + value <= maximum + COORDINATE_EPSILON; + value += interval + ) { + coordinates.push(value); + } + if ( + coordinates.length === 0 || + Math.abs(coordinates[coordinates.length - 1] - maximum) > COORDINATE_EPSILON + ) { + coordinates.push(maximum); } - return Object.freeze(coordinates); } +function freezeExtent(extent: FieldPathExtent): FieldPathExtent { + return Object.freeze(extent); +} + function verticalSegment( xMeters: number, minYMeters: number, diff --git a/packages/mobile/src/field/render/field-canvas.tsx b/packages/mobile/src/field/render/field-canvas.tsx index d0cb29fe..bae95c98 100644 --- a/packages/mobile/src/field/render/field-canvas.tsx +++ b/packages/mobile/src/field/render/field-canvas.tsx @@ -10,9 +10,11 @@ import { GestureDetector } from "react-native-gesture-handler"; import { useSharedValue, type SharedValue } from "react-native-reanimated"; import { setFieldCamera } from "../camera/field-camera-math"; +import type { FieldPresetId } from "@eight2five/drill-schema"; + import { - STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, - type StandardHighSchoolFieldTemplate, + createStandardFootballFieldTemplate, + type StandardFootballFieldTemplate, } from "../template"; import type { FieldPoint } from "../types"; import { @@ -39,7 +41,8 @@ import { } from "./field-render-tokens"; export interface FieldCanvasProps { - readonly template?: StandardHighSchoolFieldTemplate; + readonly template?: StandardFootballFieldTemplate; + readonly fieldPreset?: FieldPresetId; readonly camera?: FieldCamera; readonly defaultViewport?: FieldViewport; readonly onViewportChange?: (viewport: FieldViewport) => void; @@ -49,6 +52,7 @@ export interface FieldCanvasProps { readonly guidanceVisible?: boolean; readonly anchors?: readonly FieldAnchorGeometry[]; readonly anchorOverlayOptions?: FieldAnchorOverlayOptions; + readonly showPerimeterStepGrid?: boolean; readonly style?: StyleProp; readonly testID?: string; } @@ -56,7 +60,8 @@ export interface FieldCanvasProps { const EMPTY_FIELD_ANCHORS: readonly FieldAnchorGeometry[] = Object.freeze([]); export function FieldCanvas({ - template = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + template: explicitTemplate, + fieldPreset = "football-nfhs", camera: externalCamera, defaultViewport, onViewportChange, @@ -66,9 +71,14 @@ export function FieldCanvas({ guidanceVisible = false, anchors = EMPTY_FIELD_ANCHORS, anchorOverlayOptions = HIDDEN_FIELD_ANCHOR_OVERLAY, + showPerimeterStepGrid = false, style, testID = "field-canvas", }: FieldCanvasProps) { + const template = React.useMemo( + () => explicitTemplate ?? createStandardFootballFieldTemplate(fieldPreset), + [explicitTemplate, fieldPreset], + ); const midpoint = { xMeters: (template.bounds.minXMeters + template.bounds.maxXMeters) / 2, yMeters: (template.bounds.minYMeters + template.bounds.maxYMeters) / 2, @@ -157,6 +167,7 @@ export function FieldCanvas({ guidanceVisible={guidanceVisible} anchors={anchors} anchorOverlayOptions={anchorOverlayOptions} + showPerimeterStepGrid={showPerimeterStepGrid} /> diff --git a/packages/mobile/src/field/render/field-render-tokens.ts b/packages/mobile/src/field/render/field-render-tokens.ts index 0f5b6f12..08642685 100644 --- a/packages/mobile/src/field/render/field-render-tokens.ts +++ b/packages/mobile/src/field/render/field-render-tokens.ts @@ -1,10 +1,10 @@ -export const FIELD_FIVE_YARD_GRID_COLOR = "#6FA0E1"; +export const FIELD_FOUR_STEP_GRID_COLOR = "#6FA0E1"; export interface FieldRenderPalette { readonly canvasBackground: string; readonly stepGrid: string; readonly fieldBackground: string; - readonly fiveYardGrid: string; + readonly fourStepGrid: string; readonly fieldLines: string; readonly fieldNumbers: string; readonly livePosition: string; @@ -18,7 +18,7 @@ export const DEFAULT_FIELD_RENDER_PALETTE: FieldRenderPalette = Object.freeze({ canvasBackground: "#E7EAF0", stepGrid: "rgba(76, 93, 120, 0.22)", fieldBackground: "rgba(247, 249, 252, 0.90)", - fiveYardGrid: FIELD_FIVE_YARD_GRID_COLOR, + fourStepGrid: FIELD_FOUR_STEP_GRID_COLOR, fieldLines: "#5D6470", fieldNumbers: "#69717D", livePosition: "#3C6EC8", diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx index 9f45b8ce..905165df 100644 --- a/packages/mobile/src/field/render/field-scene.tsx +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Group } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; -import type { StandardHighSchoolFieldTemplate } from "../template"; +import type { StandardFootballFieldTemplate } from "../template"; import type { FieldPoint } from "../types"; import type { FieldCamera, @@ -22,7 +22,7 @@ import type { FieldRenderPalette } from "./field-render-tokens"; interface FieldSceneProps { readonly camera: FieldCamera; readonly canvasSize: SharedValue; - readonly template: StandardHighSchoolFieldTemplate; + readonly template: StandardFootballFieldTemplate; readonly paths: FieldPaths; readonly palette: FieldRenderPalette; readonly livePosition: SharedValue; @@ -30,6 +30,7 @@ interface FieldSceneProps { readonly guidanceVisible: boolean; readonly anchors: readonly FieldAnchorGeometry[]; readonly anchorOverlayOptions: FieldAnchorOverlayOptions; + readonly showPerimeterStepGrid: boolean; } export function FieldScene({ @@ -43,6 +44,7 @@ export function FieldScene({ guidanceVisible, anchors, anchorOverlayOptions, + showPerimeterStepGrid, }: FieldSceneProps) { const cameraTransform = useDerivedValue(() => [ { translateX: canvasSize.value.width / 2 }, @@ -60,6 +62,7 @@ export function FieldScene({ paths={paths} metersPerPixel={camera.metersPerPixel} palette={palette} + showPerimeterStepGrid={showPerimeterStepGrid} /> ; readonly palette: FieldRenderPalette; + readonly showPerimeterStepGrid: boolean; } export const FieldStaticLayer = React.memo(function FieldStaticLayer({ @@ -19,9 +20,10 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ paths, metersPerPixel, palette, + showPerimeterStepGrid, }: FieldStaticLayerProps) { const stepGridStroke = useDerivedValue(() => metersPerPixel.value * 0.7); - const fiveYardStroke = useDerivedValue(() => metersPerPixel.value * 1.1); + const fourStepStroke = useDerivedValue(() => metersPerPixel.value * 1.1); const fieldLineStroke = useDerivedValue(() => metersPerPixel.value * 1.4); const boundaryStroke = useDerivedValue(() => metersPerPixel.value * 2); const numberFont = useFont( @@ -34,23 +36,39 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ width: template.goalToGoalMeters, height: template.widthMeters, }; + const perimeterClip = { + x: paths.gridExtent.minXMeters, + y: paths.gridExtent.minYMeters, + width: paths.gridExtent.maxXMeters - paths.gridExtent.minXMeters, + height: paths.gridExtent.maxYMeters - paths.gridExtent.minYMeters, + }; return ( <> - + {showPerimeterStepGrid ? ( + + + + ) : null} + (); function point(xMeters: number, yMeters: number): FieldPoint { return Object.freeze({ xMeters, yMeters }); } +function findReference(field: ResolvedFieldDefinition, id: string): number { + const reference = field.physicalGeometry.referenceLines.find( + (line) => line.id === id, + ); + if (!reference) throw new RangeError(`Field preset is missing ${id}.`); + return reference.coordinateMeters; +} + function xLine( + bounds: StandardFootballFieldTemplate["bounds"], kind: FieldLineKind, name: string, signedYardsFromCenter: number, @@ -116,24 +141,31 @@ function xLine( name, axis: "x", coordinateMeters: xMeters, - start: point(xMeters, 0), - end: point(xMeters, FIELD_WIDTH_METERS), + start: point(xMeters, bounds.minYMeters), + end: point(xMeters, bounds.maxYMeters), yardLineYards: signedYardsFromCenter, }); } -function yLine(kind: FieldLineKind, name: string, yMeters: number): FieldLine { +function yLine( + bounds: StandardFootballFieldTemplate["bounds"], + kind: FieldLineKind, + name: string, + yMeters: number, +): FieldLine { return Object.freeze({ kind, name, axis: "y", coordinateMeters: yMeters, - start: point(-HALF_FIELD_METERS, yMeters), - end: point(HALF_FIELD_METERS, yMeters), + start: point(bounds.minXMeters, yMeters), + end: point(bounds.maxXMeters, yMeters), }); } -function makeYardNumbers(): readonly FieldYardNumber[] { +function makeYardNumbers( + bounds: StandardFootballFieldTemplate["bounds"], +): readonly FieldYardNumber[] { const numbers: FieldYardNumber[] = []; for (const xYards of [-40, -30, -20, -10, 0, 10, 20, 30, 40]) { const sideRelativeYards = xYards === 0 ? 50 : 50 - Math.abs(xYards); @@ -142,8 +174,8 @@ function makeYardNumbers(): readonly FieldYardNumber[] { for (const side of ["front", "back"] as const) { const yMeters = side === "front" - ? YARD_NUMBER_INSET_METERS - : FIELD_WIDTH_METERS - YARD_NUMBER_INSET_METERS; + ? bounds.minYMeters + YARD_NUMBER_INSET_METERS + : bounds.maxYMeters - YARD_NUMBER_INSET_METERS; numbers.push( Object.freeze({ label, @@ -160,72 +192,99 @@ function makeYardNumbers(): readonly FieldYardNumber[] { return Object.freeze(numbers); } -const goalLines = Object.freeze([ - xLine("goal-line", "Side 1 Goal Line", -50), - xLine("goal-line", "Side 2 Goal Line", 50), -] as const); -const sidelines = Object.freeze([ - yLine("sideline", "Front Sideline", 0), - yLine("sideline", "Back Sideline", FIELD_WIDTH_METERS), -] as const); -const hashLines = Object.freeze([ - yLine("hash-line", "HS FH", FRONT_HASH_Y_METERS), - yLine("hash-line", "HS BH", BACK_HASH_Y_METERS), -] as const); -const fiveYardLines = Object.freeze( - Array.from({ length: 19 }, (_, index) => { - const signedYards = -45 + index * 5; - const side = signedYards < 0 ? "Side 1" : signedYards > 0 ? "Side 2" : ""; - const labelYards = signedYards === 0 ? 50 : 50 - Math.abs(signedYards); - return xLine( - "yard-line", - signedYards === 0 ? "50 yd Line" : `${side} ${labelYards} yd Line`, - signedYards, - ); - }), -); -const allFiveYardLines = Object.freeze([ - goalLines[0], - ...fiveYardLines, - goalLines[1], -]); +function hashPrefix(fieldPreset: FieldPresetId): string { + switch (fieldPreset) { + case "football-nfhs": + return "HS"; + case "football-ncaa": + return "NCAA"; + case "football-texas-uil": + return "UIL"; + case "football-nfl": + return "NFL"; + } +} + +export function createStandardFootballFieldTemplate( + fieldPreset: FieldPresetId, +): StandardFootballFieldTemplate { + const cached = TEMPLATE_CACHE.get(fieldPreset); + if (cached) return cached; -const dimensions: StandardHighSchoolFieldDimensions = Object.freeze({ - goalToGoalYards: FIELD_LENGTH_YARDS, - widthYards: FIELD_WIDTH_YARDS, - goalToGoalMeters: FIELD_LENGTH_METERS, - widthMeters: FIELD_WIDTH_METERS, - fiveYardLineSpacingYards: 5, - fiveYardLineSpacingMeters: yardsToMeters(5), - highSchoolHashFromSidelineFeet: HASH_FROM_SIDELINE_FEET, - highSchoolHashFromSidelineMeters: HASH_FROM_SIDELINE_METERS, - yardNumberInsetFromSidelineFeet: YARD_NUMBER_INSET_FEET, - yardNumberInsetFromSidelineMeters: YARD_NUMBER_INSET_METERS, - yardNumberWidthFeet: YARD_NUMBER_WIDTH_FEET, - yardNumberHeightFeet: YARD_NUMBER_HEIGHT_FEET, - yardNumberWidthMeters: YARD_NUMBER_WIDTH_METERS, - yardNumberHeightMeters: YARD_NUMBER_HEIGHT_METERS, -}); + const fieldDefinition = getFieldPreset(fieldPreset); + const physicalBounds = fieldDefinition.physicalGeometry.bounds; + const bounds = Object.freeze({ + minXMeters: physicalBounds.minXMeters, + maxXMeters: physicalBounds.maxXMeters, + minYMeters: physicalBounds.minYMeters, + maxYMeters: physicalBounds.maxYMeters, + }); + const frontHashMeters = findReference(fieldDefinition, "front-hash"); + const backHashMeters = findReference(fieldDefinition, "back-hash"); + const frontHashFromSidelineMeters = frontHashMeters - bounds.minYMeters; + const prefix = hashPrefix(fieldPreset); + + const goalLines = Object.freeze([ + xLine(bounds, "goal-line", "Side 1 Goal Line", -50), + xLine(bounds, "goal-line", "Side 2 Goal Line", 50), + ] as const); + const sidelines = Object.freeze([ + yLine(bounds, "sideline", "Front Sideline", bounds.minYMeters), + yLine(bounds, "sideline", "Back Sideline", bounds.maxYMeters), + ] as const); + const hashLines = Object.freeze([ + yLine(bounds, "hash-line", `${prefix} FH`, frontHashMeters), + yLine(bounds, "hash-line", `${prefix} BH`, backHashMeters), + ] as const); + const fiveYardLines = Object.freeze( + Array.from({ length: 19 }, (_, index) => { + const signedYards = -45 + index * 5; + const side = signedYards < 0 ? "Side 1" : signedYards > 0 ? "Side 2" : ""; + const labelYards = signedYards === 0 ? 50 : 50 - Math.abs(signedYards); + return xLine( + bounds, + "yard-line", + signedYards === 0 ? "50 yd Line" : `${side} ${labelYards} yd Line`, + signedYards, + ); + }), + ); + const allFiveYardLines = Object.freeze([ + goalLines[0], + ...fiveYardLines, + goalLines[1], + ]); + const widthMeters = bounds.maxYMeters - bounds.minYMeters; + const goalToGoalMeters = bounds.maxXMeters - bounds.minXMeters; + const dimensions: StandardFootballFieldDimensions = Object.freeze({ + goalToGoalYards: FIELD_LENGTH_YARDS, + widthYards: metersToYards(widthMeters), + goalToGoalMeters, + widthMeters, + fiveYardLineSpacingYards: 5, + fiveYardLineSpacingMeters: yardsToMeters(5), + hashFromSidelineFeet: metersToFeet(frontHashFromSidelineMeters), + hashFromSidelineMeters: frontHashFromSidelineMeters, + highSchoolHashFromSidelineFeet: metersToFeet(frontHashFromSidelineMeters), + highSchoolHashFromSidelineMeters: frontHashFromSidelineMeters, + yardNumberInsetFromSidelineFeet: YARD_NUMBER_INSET_FEET, + yardNumberInsetFromSidelineMeters: YARD_NUMBER_INSET_METERS, + yardNumberWidthFeet: YARD_NUMBER_WIDTH_FEET, + yardNumberHeightFeet: YARD_NUMBER_HEIGHT_FEET, + yardNumberWidthMeters: YARD_NUMBER_WIDTH_METERS, + yardNumberHeightMeters: YARD_NUMBER_HEIGHT_METERS, + }); -/** - * Exact NFHS physical geometry in Eight2Five's centered physical coordinate - * space. Conventional marching-grid positions (including 28/56 hashes) live - * in @eight2five/drill-schema and are projected onto this geometry. - */ -export const STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE: StandardHighSchoolFieldTemplate = - Object.freeze({ - name: "standard-high-school", + const template: StandardFootballFieldTemplate = Object.freeze({ + name: "standard-football", + fieldPreset, + fieldDefinition, dimensions, goalToGoalYards: FIELD_LENGTH_YARDS, - widthYards: FIELD_WIDTH_YARDS, - goalToGoalMeters: FIELD_LENGTH_METERS, - widthMeters: FIELD_WIDTH_METERS, - bounds: Object.freeze({ - minXMeters: -HALF_FIELD_METERS, - maxXMeters: HALF_FIELD_METERS, - minYMeters: 0, - maxYMeters: FIELD_WIDTH_METERS, - }), + widthYards: metersToYards(widthMeters), + goalToGoalMeters, + widthMeters, + bounds, goalLines, sidelines, hashLines, @@ -234,26 +293,43 @@ export const STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE: StandardHighSchoolFieldTemplat fiveYardLines, allFiveYardLines, yardLines: fiveYardLines, - yardNumbers: makeYardNumbers(), + yardNumbers: makeYardNumbers(bounds), }); + TEMPLATE_CACHE.set(fieldPreset, template); + return template; +} + +/** Exact NFHS physical geometry plus its conventional 160 x 84 marching grid. */ +export const STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE = + createStandardFootballFieldTemplate("football-nfhs"); export const STANDARD_HIGH_SCHOOL_FIELD = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; export const STANDARD_FIELD_TEMPLATE = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; export function getStandardFieldDimensionsInFeet() { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; return Object.freeze({ - goalToGoalFeet: metersToFeet(FIELD_LENGTH_METERS), - widthFeet: metersToFeet(FIELD_WIDTH_METERS), - frontHashFromSidelineFeet: metersToFeet(FRONT_HASH_Y_METERS), - backHashFromFrontSidelineFeet: metersToFeet(BACK_HASH_Y_METERS), + goalToGoalFeet: metersToFeet(field.goalToGoalMeters), + widthFeet: metersToFeet(field.widthMeters), + frontHashFromSidelineFeet: metersToFeet( + field.frontHashLine.coordinateMeters - field.bounds.minYMeters, + ), + backHashFromFrontSidelineFeet: metersToFeet( + field.backHashLine.coordinateMeters - field.bounds.minYMeters, + ), }); } export function getStandardFieldDimensionsInYards() { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; return Object.freeze({ - goalToGoalYards: metersToYards(FIELD_LENGTH_METERS), - widthYards: metersToYards(FIELD_WIDTH_METERS), - frontHashFromFrontSidelineYards: metersToYards(FRONT_HASH_Y_METERS), - backHashFromFrontSidelineYards: metersToYards(BACK_HASH_Y_METERS), + goalToGoalYards: metersToYards(field.goalToGoalMeters), + widthYards: metersToYards(field.widthMeters), + frontHashFromFrontSidelineYards: metersToYards( + field.frontHashLine.coordinateMeters - field.bounds.minYMeters, + ), + backHashFromFrontSidelineYards: metersToYards( + field.backHashLine.coordinateMeters - field.bounds.minYMeters, + ), }); } diff --git a/packages/mobile/src/mobile-repositories.ts b/packages/mobile/src/mobile-repositories.ts index cea3621e..756069b8 100644 --- a/packages/mobile/src/mobile-repositories.ts +++ b/packages/mobile/src/mobile-repositories.ts @@ -1,7 +1,7 @@ import type { SQLiteDatabase } from "expo-sqlite"; import { MOBILE_DB_NAME, - migrateMobileDatabase, + prepareMobileDatabase, } from "./storage/mobileDatabase"; import { SqliteDrillRepository } from "./drill/SqliteDrillRepository"; import { SqliteSettingsRepository } from "./settings/SqliteSettingsRepository"; @@ -16,8 +16,8 @@ export interface OpenMobileRepositoriesResult { * Open the app-side repositories over one database connection. * * `expo-sqlite` is imported lazily so consumers of the pure field and drill - * helpers do not load native SQLite at module evaluation time. Migration is - * owned here and runs before either repository is exposed. + * helpers do not load native SQLite at module evaluation time. Schema + * preparation is owned here and runs before either repository is exposed. */ export async function openMobileRepositories( databaseName = MOBILE_DB_NAME, @@ -25,7 +25,7 @@ export async function openMobileRepositories( const { openDatabaseAsync } = await import("expo-sqlite"); const database = await openDatabaseAsync(databaseName); try { - await migrateMobileDatabase(database); + await prepareMobileDatabase(database); } catch (cause) { await closeQuietly(database); throw cause; @@ -42,6 +42,6 @@ async function closeQuietly(database: SQLiteDatabase): Promise { try { await database.closeAsync(); } catch { - // Preserve the migration/opening error rather than masking it with close. + // Preserve the schema/opening error rather than masking it with close. } } diff --git a/packages/mobile/src/settings/SqliteSettingsRepository.ts b/packages/mobile/src/settings/SqliteSettingsRepository.ts index 87cc86ab..101c6877 100644 --- a/packages/mobile/src/settings/SqliteSettingsRepository.ts +++ b/packages/mobile/src/settings/SqliteSettingsRepository.ts @@ -45,21 +45,25 @@ export class SqliteSettingsRepository implements AppSettingsRepository { SET drill_features_enabled = ?, drill_terminology = 'sets', field_perspective = ?, + default_field_preset = ?, transition_metric_mode = ?, guidance_enabled = ?, developer_mode_enabled = ?, show_cached_anchor_geometry = ?, show_comfortable_anchor_range = ?, + show_perimeter_step_grid = ?, comfortable_anchor_range_meters = ? WHERE singleton_id = ?`, [ boolToSql(DEFAULT_APP_SETTINGS.drillFeaturesEnabled), DEFAULT_APP_SETTINGS.fieldPerspective, + DEFAULT_APP_SETTINGS.defaultFieldPreset, DEFAULT_APP_SETTINGS.transitionMetricMode, boolToSql(DEFAULT_APP_SETTINGS.guidanceEnabled), boolToSql(DEFAULT_APP_SETTINGS.developerModeEnabled), boolToSql(DEFAULT_APP_SETTINGS.showCachedAnchorGeometry), boolToSql(DEFAULT_APP_SETTINGS.showComfortableAnchorRange), + boolToSql(DEFAULT_APP_SETTINGS.showPerimeterStepGrid), DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, 1, ], @@ -73,11 +77,13 @@ export class SqliteSettingsRepository implements AppSettingsRepository { drill_features_enabled, drill_terminology, field_perspective, + default_field_preset, transition_metric_mode, guidance_enabled, developer_mode_enabled, show_cached_anchor_geometry, show_comfortable_anchor_range, + show_perimeter_step_grid, comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id @@ -95,24 +101,28 @@ export class SqliteSettingsRepository implements AppSettingsRepository { drill_features_enabled, drill_terminology, field_perspective, + default_field_preset, transition_metric_mode, guidance_enabled, developer_mode_enabled, show_cached_anchor_geometry, show_comfortable_anchor_range, + show_perimeter_step_grid, comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id - ) VALUES (?, ?, 'sets', ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, 'sets', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton_id) DO UPDATE SET drill_features_enabled = excluded.drill_features_enabled, drill_terminology = 'sets', field_perspective = excluded.field_perspective, + default_field_preset = excluded.default_field_preset, transition_metric_mode = excluded.transition_metric_mode, guidance_enabled = excluded.guidance_enabled, developer_mode_enabled = excluded.developer_mode_enabled, show_cached_anchor_geometry = excluded.show_cached_anchor_geometry, show_comfortable_anchor_range = excluded.show_comfortable_anchor_range, + show_perimeter_step_grid = excluded.show_perimeter_step_grid, comfortable_anchor_range_meters = excluded.comfortable_anchor_range_meters, active_drill_id = excluded.active_drill_id, selected_drill_page_id = excluded.selected_drill_page_id`, @@ -120,11 +130,13 @@ export class SqliteSettingsRepository implements AppSettingsRepository { 1, boolToSql(normalized.drillFeaturesEnabled), normalized.fieldPerspective, + normalized.defaultFieldPreset, normalized.transitionMetricMode, boolToSql(normalized.guidanceEnabled), boolToSql(normalized.developerModeEnabled), boolToSql(normalized.showCachedAnchorGeometry), boolToSql(normalized.showComfortableAnchorRange), + boolToSql(normalized.showPerimeterStepGrid), normalized.comfortableAnchorRangeMeters, normalized.activeDrillId, normalized.selectedDrillSetId, @@ -142,6 +154,7 @@ function fromRow(row: AppSettingsRow): AppSettings { return normalizeAppSettings({ drillFeaturesEnabled: sqliteBoolean(row.drill_features_enabled), fieldPerspective: row.field_perspective, + defaultFieldPreset: row.default_field_preset, transitionMetricMode: row.transition_metric_mode, guidanceEnabled: sqliteBoolean(row.guidance_enabled), developerModeEnabled: sqliteBoolean(row.developer_mode_enabled), @@ -149,6 +162,7 @@ function fromRow(row: AppSettingsRow): AppSettings { showComfortableAnchorRange: sqliteBoolean( row.show_comfortable_anchor_range, ), + showPerimeterStepGrid: sqliteBoolean(row.show_perimeter_step_grid), comfortableAnchorRangeMeters: row.comfortable_anchor_range_meters, activeDrillId: row.active_drill_id, selectedDrillSetId: row.selected_drill_page_id, @@ -160,6 +174,7 @@ function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { row.drill_features_enabled === boolToSql(settings.drillFeaturesEnabled) && row.drill_terminology === "sets" && row.field_perspective === settings.fieldPerspective && + row.default_field_preset === settings.defaultFieldPreset && row.transition_metric_mode === settings.transitionMetricMode && row.guidance_enabled === boolToSql(settings.guidanceEnabled) && row.developer_mode_enabled === boolToSql(settings.developerModeEnabled) && @@ -167,6 +182,8 @@ function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { boolToSql(settings.showCachedAnchorGeometry) && row.show_comfortable_anchor_range === boolToSql(settings.showComfortableAnchorRange) && + row.show_perimeter_step_grid === + boolToSql(settings.showPerimeterStepGrid) && row.comfortable_anchor_range_meters === settings.comfortableAnchorRangeMeters && row.active_drill_id === settings.activeDrillId && diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts index ab98b6ef..7ad486cd 100644 --- a/packages/mobile/src/settings/__tests__/repository.test.ts +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -1,3 +1,4 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; import type { SQLiteDatabase } from "expo-sqlite"; import { SqliteSettingsRepository } from "../SqliteSettingsRepository"; import { @@ -17,25 +18,31 @@ describe("app settings", () => { drill_features_enabled: 1, drill_terminology: "sets", field_perspective: "director", + default_field_preset: "football-nfhs", transition_metric_mode: "step-size", guidance_enabled: 1, developer_mode_enabled: 0, + show_cached_anchor_geometry: 0, + show_comfortable_anchor_range: 0, + show_perimeter_step_grid: 0, comfortable_anchor_range_meters: 20, active_drill_id: null, selected_drill_page_id: null, }); }); - test("normalizes invalid persisted values and pins legacy terminology to sets", async () => { + test("normalizes invalid persisted values and pins terminology to sets", async () => { const fake = new SettingsFakeDatabase({ drill_features_enabled: 2, drill_terminology: "pages", field_perspective: "unknown", + default_field_preset: "unknown", transition_metric_mode: "unknown", guidance_enabled: "yes", developer_mode_enabled: 0, show_cached_anchor_geometry: 1, show_comfortable_anchor_range: 1, + show_perimeter_step_grid: 1, comfortable_anchor_range_meters: Number.NaN, active_drill_id: 17, selected_drill_page_id: "", @@ -48,6 +55,7 @@ describe("app settings", () => { ...DEFAULT_APP_SETTINGS, showCachedAnchorGeometry: true, showComfortableAnchorRange: true, + showPerimeterStepGrid: true, }); expect(fake.row?.drill_terminology).toBe("sets"); expect(fake.database.runAsync).toHaveBeenCalled(); @@ -55,19 +63,22 @@ describe("app settings", () => { developerModeEnabled: false, showCachedAnchorGeometry: false, showComfortableAnchorRange: false, + showPerimeterStepGrid: false, }); }); test("updates supplied fields while preserving drill/set selection", async () => { const fake = new SettingsFakeDatabase({ drill_features_enabled: 1, - drill_terminology: "pages", + drill_terminology: "sets", field_perspective: "director", + default_field_preset: "football-ncaa", transition_metric_mode: "step-size", guidance_enabled: 1, developer_mode_enabled: 1, show_cached_anchor_geometry: 1, show_comfortable_anchor_range: 1, + show_perimeter_step_grid: 1, comfortable_anchor_range_meters: 30, active_drill_id: "drill-1", selected_drill_page_id: "set-1", @@ -81,6 +92,7 @@ describe("app settings", () => { expect(updated).toMatchObject({ drillFeaturesEnabled: true, drillTerminology: "sets", + defaultFieldPreset: "football-ncaa", comfortableAnchorRangeMeters: 20, activeDrillId: "drill-1", selectedDrillSetId: "set-1", @@ -89,18 +101,21 @@ describe("app settings", () => { expect(updated.developerModeEnabled).toBe(true); expect(updated.showCachedAnchorGeometry).toBe(true); expect(updated.showComfortableAnchorRange).toBe(true); + expect(updated.showPerimeterStepGrid).toBe(true); }); test("resetPreferences restores preference fields but preserves selection", async () => { const fake = new SettingsFakeDatabase({ drill_features_enabled: 0, - drill_terminology: "pages", + drill_terminology: "sets", field_perspective: "performer", + default_field_preset: "football-nfl", transition_metric_mode: "crossing-counts", guidance_enabled: 0, developer_mode_enabled: 1, show_cached_anchor_geometry: 1, show_comfortable_anchor_range: 1, + show_perimeter_step_grid: 1, comfortable_anchor_range_meters: 7, active_drill_id: "drill-1", selected_drill_page_id: "set-2", @@ -122,6 +137,7 @@ describe("app settings", () => { expect( normalizeAppSettings({ drillFeaturesEnabled: "true", + defaultFieldPreset: "football-made-up", guidanceEnabled: null, comfortableAnchorRangeMeters: 0, activeDrillId: " ", @@ -129,6 +145,15 @@ describe("app settings", () => { ).toEqual(DEFAULT_APP_SETTINGS); }); + test.each(FIELD_PRESET_IDS)( + "accepts %s as the default marching field", + (defaultFieldPreset) => { + expect( + normalizeAppSettings({ defaultFieldPreset }).defaultFieldPreset, + ).toBe(defaultFieldPreset); + }, + ); + test("clears an impossible selected set when no drill is active", () => { expect( normalizeAppSettings({ @@ -138,15 +163,26 @@ describe("app settings", () => { ).toBeNull(); }); - test("gates range behind geometry and rejects ranges over 200 meters", () => { + test("gates developer overlays and rejects ranges over 200 meters", () => { expect( getEffectiveDeveloperOverlaySettings({ ...DEFAULT_APP_SETTINGS, developerModeEnabled: true, showCachedAnchorGeometry: false, showComfortableAnchorRange: true, + showPerimeterStepGrid: true, + }), + ).toMatchObject({ + showComfortableAnchorRange: false, + showPerimeterStepGrid: true, + }); + expect( + getEffectiveDeveloperOverlaySettings({ + ...DEFAULT_APP_SETTINGS, + developerModeEnabled: false, + showPerimeterStepGrid: true, }), - ).toMatchObject({ showComfortableAnchorRange: false }); + ).toMatchObject({ showPerimeterStepGrid: false }); expect( normalizeAppSettings({ comfortableAnchorRangeMeters: 201 }), ).toMatchObject({ comfortableAnchorRangeMeters: 20 }); @@ -168,26 +204,30 @@ class SettingsFakeDatabase { drill_features_enabled: params[0], drill_terminology: "sets", field_perspective: params[1], - transition_metric_mode: params[2], - guidance_enabled: params[3], - developer_mode_enabled: params[4], - show_cached_anchor_geometry: params[5], - show_comfortable_anchor_range: params[6], - comfortable_anchor_range_meters: params[7], + default_field_preset: params[2], + transition_metric_mode: params[3], + guidance_enabled: params[4], + developer_mode_enabled: params[5], + show_cached_anchor_geometry: params[6], + show_comfortable_anchor_range: params[7], + show_perimeter_step_grid: params[8], + comfortable_anchor_range_meters: params[9], }; } else { this.row = { drill_features_enabled: params[1], drill_terminology: "sets", field_perspective: params[2], - transition_metric_mode: params[3], - guidance_enabled: params[4], - developer_mode_enabled: params[5], - show_cached_anchor_geometry: params[6], - show_comfortable_anchor_range: params[7], - comfortable_anchor_range_meters: params[8], - active_drill_id: params[9], - selected_drill_page_id: params[10], + default_field_preset: params[3], + transition_metric_mode: params[4], + guidance_enabled: params[5], + developer_mode_enabled: params[6], + show_cached_anchor_geometry: params[7], + show_comfortable_anchor_range: params[8], + show_perimeter_step_grid: params[9], + comfortable_anchor_range_meters: params[10], + active_drill_id: params[11], + selected_drill_page_id: params[12], }; } return { lastInsertRowId: 1, changes: 1 }; diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts index ef949e43..b15dde62 100644 --- a/packages/mobile/src/settings/types.ts +++ b/packages/mobile/src/settings/types.ts @@ -1,3 +1,5 @@ +import { isFieldPresetId, type FieldPresetId } from "@eight2five/drill-schema"; + export type FieldPerspective = "director" | "performer"; export type TransitionMetricMode = "step-size" | "crossing-counts"; @@ -7,14 +9,16 @@ export const MAX_COMFORTABLE_ANCHOR_RANGE_METERS = 200; /** App preferences plus persisted drill/set selection pointers. */ export interface AppSettings { readonly drillFeaturesEnabled: boolean; - /** @deprecated Drill terminology is fixed to Sets in v2. */ + /** @deprecated Drill terminology is fixed to Sets. */ readonly drillTerminology: "sets"; readonly fieldPerspective: FieldPerspective; + readonly defaultFieldPreset: FieldPresetId; readonly transitionMetricMode: TransitionMetricMode; readonly guidanceEnabled: boolean; readonly developerModeEnabled: boolean; readonly showCachedAnchorGeometry: boolean; readonly showComfortableAnchorRange: boolean; + readonly showPerimeterStepGrid: boolean; readonly comfortableAnchorRangeMeters: number; readonly activeDrillId: string | null; readonly selectedDrillSetId: string | null; @@ -26,11 +30,13 @@ export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ drillFeaturesEnabled: true, drillTerminology: "sets", fieldPerspective: "director", + defaultFieldPreset: "football-nfhs", transitionMetricMode: "step-size", guidanceEnabled: true, developerModeEnabled: false, showCachedAnchorGeometry: false, showComfortableAnchorRange: false, + showPerimeterStepGrid: false, comfortableAnchorRangeMeters: DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS, activeDrillId: null, selectedDrillSetId: null, @@ -40,11 +46,13 @@ export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ export const APP_PREFERENCE_KEYS = Object.freeze([ "drillFeaturesEnabled", "fieldPerspective", + "defaultFieldPreset", "transitionMetricMode", "guidanceEnabled", "developerModeEnabled", "showCachedAnchorGeometry", "showComfortableAnchorRange", + "showPerimeterStepGrid", "comfortableAnchorRangeMeters", ] as const satisfies readonly (keyof AppSettings)[]); @@ -72,6 +80,9 @@ export function normalizeAppSettings(value?: unknown): AppSettings { candidate.fieldPerspective === "performer" ? candidate.fieldPerspective : DEFAULT_APP_SETTINGS.fieldPerspective, + defaultFieldPreset: isFieldPresetId(candidate.defaultFieldPreset) + ? candidate.defaultFieldPreset + : DEFAULT_APP_SETTINGS.defaultFieldPreset, transitionMetricMode: candidate.transitionMetricMode === "step-size" || candidate.transitionMetricMode === "crossing-counts" @@ -93,6 +104,10 @@ export function normalizeAppSettings(value?: unknown): AppSettings { candidate.showComfortableAnchorRange, DEFAULT_APP_SETTINGS.showComfortableAnchorRange, ), + showPerimeterStepGrid: booleanOrDefault( + candidate.showPerimeterStepGrid, + DEFAULT_APP_SETTINGS.showPerimeterStepGrid, + ), comfortableAnchorRangeMeters: positiveFiniteOrDefault( candidate.comfortableAnchorRangeMeters, DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, @@ -120,6 +135,7 @@ export function getEffectiveAppSettings(value: AppSettings): AppSettings { ...normalized, showCachedAnchorGeometry: false, showComfortableAnchorRange: false, + showPerimeterStepGrid: false, }; } @@ -130,6 +146,7 @@ export const selectEffectiveAppSettings = getEffectiveAppSettings; export interface EffectiveDeveloperOverlaySettings { readonly showCachedAnchorGeometry: boolean; readonly showComfortableAnchorRange: boolean; + readonly showPerimeterStepGrid: boolean; } export function getEffectiveDeveloperOverlaySettings( @@ -140,6 +157,7 @@ export function getEffectiveDeveloperOverlaySettings( showCachedAnchorGeometry: settings.showCachedAnchorGeometry, showComfortableAnchorRange: settings.showCachedAnchorGeometry && settings.showComfortableAnchorRange, + showPerimeterStepGrid: settings.showPerimeterStepGrid, }; } @@ -154,6 +172,10 @@ export function selectShowComfortableAnchorRange(value: AppSettings): boolean { return getEffectiveAppSettings(value).showComfortableAnchorRange; } +export function selectShowPerimeterStepGrid(value: AppSettings): boolean { + return getEffectiveAppSettings(value).showPerimeterStepGrid; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index e6950210..a6d2e0c6 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -1,170 +1,92 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; import type { SQLiteDatabase } from "expo-sqlite"; import { - migrateMobileDatabase, MOBILE_DB_NAME, MOBILE_SCHEMA_VERSION, - parseLegacySetLabel, + prepareMobileDatabase, } from "../mobileDatabase"; -describe("mobile app SQLite migration", () => { - test("creates the v2 relational schema, set model, defaults, WAL, and foreign keys", async () => { +describe("mobile app SQLite schema preparation", () => { + test("creates the current disposable development schema", async () => { const executed: string[] = []; const database = fakeDatabase(0, executed); - await migrateMobileDatabase(database); + await prepareMobileDatabase(database); const sql = executed.join("\n"); expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); - expect(MOBILE_SCHEMA_VERSION).toBe(2); + expect(MOBILE_SCHEMA_VERSION).toBe(3); expect(sql).toContain("PRAGMA journal_mode = WAL"); - expect(sql).toContain("PRAGMA foreign_keys = ON"); - expect(sql).toContain( - "CREATE TABLE IF NOT EXISTS mobile_schema_migrations", - ); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS drills"); + expect(sql).toContain("PRAGMA foreign_keys = OFF"); + expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); + expect(sql).toContain("DROP TABLE IF EXISTS drill_pages"); + expect(sql).toContain("CREATE TABLE drills"); + expect(sql).toContain("CREATE TABLE drill_sets"); expect(sql).toContain("field_preset TEXT NOT NULL DEFAULT 'football-nfhs'"); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS drill_pages"); + for (const fieldPreset of FIELD_PRESET_IDS) { + expect(sql).toContain(`'${fieldPreset}'`); + } expect(sql).toContain("set_number INTEGER NOT NULL"); - expect(sql).toContain("set_kind TEXT NOT NULL"); - expect(sql).toContain("measure_start INTEGER"); expect(sql).toContain("x_steps REAL NOT NULL"); - expect(sql).toContain("facing_degrees REAL"); - expect(sql).toContain("CREATE TABLE IF NOT EXISTS app_settings"); + expect(sql).not.toContain("x_meters REAL"); + expect(sql).not.toContain("y_meters REAL"); + expect(sql).toContain("CREATE TABLE app_settings"); + expect(sql).toContain("default_field_preset TEXT NOT NULL"); + expect(sql).toContain("show_perimeter_step_grid INTEGER NOT NULL"); expect(sql).toContain("REFERENCES drills(id) ON DELETE CASCADE"); - expect(sql).toContain("REFERENCES drills(id) ON DELETE SET NULL"); - expect(sql).toContain("REFERENCES drill_pages(id) ON DELETE SET NULL"); - expect(sql).toContain("UNIQUE (drill_id, ordinal)"); - expect(sql).toContain("idx_drill_sets_drill"); - expect(sql).toContain("DEFAULT 'sets'"); - expect(sql).toContain("DEFAULT 'director'"); - expect(sql).toContain("DEFAULT 'step-size'"); - expect(sql).toContain("DEFAULT 20"); + expect(sql).toContain("REFERENCES drill_sets(id) ON DELETE SET NULL"); expect(sql).toContain(`PRAGMA user_version = ${MOBILE_SCHEMA_VERSION}`); - expect(database.runAsync).toHaveBeenCalledWith( - expect.stringContaining("mobile_schema_migrations"), - [MOBILE_SCHEMA_VERSION, expect.any(Number)], - ); expect(database.withTransactionAsync).toHaveBeenCalledTimes(1); }); - test("migrates v1 labels and physical coordinates into explicit set/grid fields", async () => { + test("destructively rebuilds an older development layout without migrations", async () => { const executed: string[] = []; - const database = fakeDatabase(1, executed, [ - { - id: "legacy-1", - drill_id: "drill", - ordinal: 0, - label: "31", - counts_from_previous: 8, - x_meters: 45.72, - y_meters: 0, - }, - { - id: "legacy-2", - drill_id: "drill", - ordinal: 1, - label: "31A", - counts_from_previous: 8, - x_meters: 45.72, - y_meters: 16.256, - }, - { - id: "legacy-3", - drill_id: "drill", - ordinal: 2, - label: "Finale", - counts_from_previous: 2.5, - x_meters: 45.72, - y_meters: 32.512, - }, - ]); + const database = fakeDatabase(MOBILE_SCHEMA_VERSION - 1, executed); - await migrateMobileDatabase(database); + await prepareMobileDatabase(database); const sql = executed.join("\n"); - expect(sql).toContain("ADD COLUMN set_number INTEGER"); - expect(sql).toContain("ADD COLUMN x_steps REAL"); - expect(database.runAsync).toHaveBeenCalledWith( - expect.stringContaining("drill_terminology = 'sets'"), - ); - expect(sql).toContain("PRAGMA user_version = 2"); - - const migrationUpdates = database.runAsync.mock.calls.filter( - ([statement]) => String(statement).includes("SET set_number = ?"), - ); - expect(migrationUpdates).toHaveLength(3); - expect(migrationUpdates[0][1]).toEqual([ - 31, - null, - "set", - 0, - expect.closeTo(0, 8), - expect.closeTo(0, 8), - "31", - "legacy-1", - ]); - expect(migrationUpdates[1][1]).toEqual([ - 31, - "A", - "subset", - 8, - expect.closeTo(0, 8), - expect.closeTo(28, 8), - "31A", - "legacy-2", - ]); - expect(migrationUpdates[2][1]).toEqual([ - 3, - null, - "set", - 3, - expect.closeTo(0, 8), - expect.closeTo(56, 8), - "3", - "legacy-3", - ]); + expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); + expect(sql).toContain("DROP TABLE IF EXISTS drill_sets"); + expect(sql).toContain("DROP TABLE IF EXISTS drills"); + expect(sql).toContain("CREATE TABLE app_settings"); + expect(sql).not.toContain("ALTER TABLE"); + expect(sql).not.toContain("mobile_schema_migrations ("); + expect(database.withTransactionAsync).toHaveBeenCalledTimes(1); }); - test("parses only safe numeric and supported subset legacy labels", () => { - expect(parseLegacySetLabel("31")).toEqual({ number: 31, kind: "set" }); - expect(parseLegacySetLabel("31A")).toEqual({ - number: 31, - suffix: "A", - kind: "subset", - }); - expect(parseLegacySetLabel("31.5")).toEqual({ - number: 31, - suffix: ".5", - kind: "subset", - }); - expect(parseLegacySetLabel("Finale")).toBeUndefined(); + test("keeps a current schema without rebuilding it", async () => { + const executed: string[] = []; + const database = fakeDatabase(MOBILE_SCHEMA_VERSION, executed); + + await prepareMobileDatabase(database); + + const sql = executed.join("\n"); + expect(sql).toContain("PRAGMA journal_mode = WAL"); + expect(sql).toContain("PRAGMA foreign_keys = ON"); + expect(sql).not.toContain("DROP TABLE"); + expect(sql).not.toContain("CREATE TABLE"); + expect(database.withTransactionAsync).not.toHaveBeenCalled(); }); - test("rejects a database newer than the package schema without migrating it", async () => { + test("rejects a database newer than the package schema", async () => { const executed: string[] = []; const database = fakeDatabase(MOBILE_SCHEMA_VERSION + 1, executed); - await expect(migrateMobileDatabase(database)).rejects.toThrow( + await expect(prepareMobileDatabase(database)).rejects.toThrow( `Unsupported mobile database version ${MOBILE_SCHEMA_VERSION + 1}`, ); expect(database.withTransactionAsync).not.toHaveBeenCalled(); - expect(executed.join("\n")).not.toContain( - "CREATE TABLE IF NOT EXISTS drills", - ); + expect(executed.join("\n")).not.toContain("DROP TABLE"); }); }); -function fakeDatabase( - version: number, - executed: string[], - rows: readonly Record[] = [], -) { +function fakeDatabase(version: number, executed: string[]) { return { execAsync: jest.fn(async (sql: string) => { executed.push(sql); }), getFirstAsync: jest.fn(async () => ({ user_version: version })), - getAllAsync: jest.fn(async () => rows.map((row) => ({ ...row }))), runAsync: jest.fn(async () => ({ lastInsertRowId: 1, changes: 1 })), withTransactionAsync: jest.fn( async (task: () => Promise) => await task(), diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index ee98cf08..78395c43 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -1,8 +1,4 @@ -import { - formatSetName, - getFieldPreset, - physicalPointToDrillGrid, -} from "@eight2five/drill-schema"; +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; import type { SQLiteDatabase } from "expo-sqlite"; /** The app database is deliberately separate from the PANS manager database. */ @@ -10,22 +6,19 @@ export const MOBILE_DB_NAME = "eight2five-mobile.db"; export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; /** - * v2 replaces arbitrary drill-page labels/physical target coordinates with - * explicit set identity and conventional drill-grid coordinates. Legacy - * columns remain in the physical SQLite table so v1 databases can migrate - * without rebuilding foreign-key relationships. + * The app-side schema is still under active development. Until it is declared + * stable, a version mismatch intentionally rebuilds this disposable database + * rather than carrying migration code for development-only layouts. */ -export const MOBILE_SCHEMA_VERSION = 2; +export const MOBILE_SCHEMA_VERSION = 3; -export const MOBILE_SCHEMA_MIGRATIONS_TABLE = "mobile_schema_migrations"; export const DRILLS_TABLE = "drills"; -export const DRILL_SETS_TABLE = "drill_pages"; -/** @deprecated Physical table alias retained for storage compatibility. */ -export const DRILL_PAGES_TABLE = DRILL_SETS_TABLE; +export const DRILL_SETS_TABLE = "drill_sets"; export const APP_SETTINGS_TABLE = "app_settings"; -const NFHS_FIELD = getFieldPreset("football-nfhs"); -const HALF_FIELD_METERS = 45.72; +const FIELD_PRESET_SQL_LIST = FIELD_PRESET_IDS.map((id) => `'${id}'`).join( + ", ", +); export class MobileStorageError extends Error { readonly cause?: unknown; @@ -38,296 +31,135 @@ export class MobileStorageError extends Error { } /** - * Migrate the app-side database. This is the only migration owner for this - * database; repositories only consume a completed schema. + * Prepare the app-side database for use. + * + * During active schema development, any older local layout is discarded and + * recreated from the current definition. The separate PANS manager database is + * not touched by this routine. */ -export async function migrateMobileDatabase(db: SQLiteDatabase): Promise { +export async function prepareMobileDatabase(db: SQLiteDatabase): Promise { await db.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); const row = await db.getFirstAsync<{ user_version: number | string }>( "PRAGMA user_version", ); - let currentVersion = parseSchemaVersion(row?.user_version); + const currentVersion = parseSchemaVersion(row?.user_version); if (currentVersion > MOBILE_SCHEMA_VERSION) { throw new MobileStorageError( `Unsupported mobile database version ${currentVersion}.`, ); } - if (currentVersion === 0) { - await createCurrentSchema(db); - currentVersion = MOBILE_SCHEMA_VERSION; - } - - if (currentVersion === 1) { - await migrateVersionOneToTwo(db); - currentVersion = 2; - } - if (currentVersion !== MOBILE_SCHEMA_VERSION) { - throw new MobileStorageError( - `Mobile database migration stopped at unsupported version ${currentVersion}.`, - ); + await rebuildMobileDatabase(db); } - // Keep this enabled for every connection, including an already migrated one. + // Foreign-key enforcement is connection-local, so enable it on every open. await db.execAsync("PRAGMA foreign_keys = ON;"); } -async function createCurrentSchema(db: SQLiteDatabase): Promise { - await db.withTransactionAsync(async () => { - await db.execAsync(` - CREATE TABLE IF NOT EXISTS ${MOBILE_SCHEMA_MIGRATIONS_TABLE} ( - version INTEGER PRIMARY KEY NOT NULL, - applied_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS ${DRILLS_TABLE} ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL CHECK (length(trim(name)) > 0), - field_preset TEXT NOT NULL DEFAULT 'football-nfhs' - CHECK (field_preset = 'football-nfhs'), - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_drills_created_at - ON ${DRILLS_TABLE}(created_at, id); - - CREATE TABLE IF NOT EXISTS ${DRILL_SETS_TABLE} ( - id TEXT PRIMARY KEY NOT NULL, - drill_id TEXT NOT NULL - REFERENCES ${DRILLS_TABLE}(id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL - CHECK (ordinal >= 0 AND ordinal = CAST(ordinal AS INTEGER)), - - set_number INTEGER NOT NULL CHECK (set_number >= 0), - set_suffix TEXT, - set_kind TEXT NOT NULL CHECK (set_kind IN ('set', 'subset')), - counts_from_previous INTEGER NOT NULL - CHECK ( - counts_from_previous >= 0 AND - counts_from_previous = CAST(counts_from_previous AS INTEGER) - ), - measure_start INTEGER, - measure_end INTEGER, - x_steps REAL NOT NULL CHECK (x_steps = x_steps), - y_steps REAL NOT NULL CHECK (y_steps = y_steps), - facing_degrees REAL - CHECK (facing_degrees IS NULL OR (facing_degrees >= 0 AND facing_degrees < 360)), - - -- Legacy compatibility columns. New domain code derives these values. - label TEXT NOT NULL CHECK (length(trim(label)) > 0), - x_meters REAL NOT NULL CHECK (x_meters = x_meters), - y_meters REAL NOT NULL CHECK (y_meters = y_meters), - - CHECK ( - (set_kind = 'set' AND set_suffix IS NULL) OR - (set_kind = 'subset' AND set_suffix IS NOT NULL) - ), - CHECK ( - (measure_start IS NULL AND measure_end IS NULL) OR - (measure_start IS NOT NULL AND measure_end IS NOT NULL AND - measure_start >= 0 AND measure_end >= measure_start) - ), - UNIQUE (drill_id, ordinal), - UNIQUE (drill_id, set_number, set_suffix) - ); - - CREATE INDEX IF NOT EXISTS idx_drill_sets_drill - ON ${DRILL_SETS_TABLE}(drill_id, ordinal, id); - - CREATE TABLE IF NOT EXISTS ${APP_SETTINGS_TABLE} ( - singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), - drill_features_enabled INTEGER NOT NULL DEFAULT 1 - CHECK (drill_features_enabled IN (0, 1)), - drill_terminology TEXT NOT NULL DEFAULT 'sets' - CHECK (drill_terminology IN ('pages', 'sets')), - field_perspective TEXT NOT NULL DEFAULT 'director' - CHECK (field_perspective IN ('director', 'performer')), - transition_metric_mode TEXT NOT NULL DEFAULT 'step-size' - CHECK (transition_metric_mode IN ('step-size', 'crossing-counts')), - guidance_enabled INTEGER NOT NULL DEFAULT 1 - CHECK (guidance_enabled IN (0, 1)), - developer_mode_enabled INTEGER NOT NULL DEFAULT 0 - CHECK (developer_mode_enabled IN (0, 1)), - show_cached_anchor_geometry INTEGER NOT NULL DEFAULT 0 - CHECK (show_cached_anchor_geometry IN (0, 1)), - show_comfortable_anchor_range INTEGER NOT NULL DEFAULT 0 - CHECK (show_comfortable_anchor_range IN (0, 1)), - comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 - CHECK (comfortable_anchor_range_meters > 0), - active_drill_id TEXT - REFERENCES ${DRILLS_TABLE}(id) ON DELETE SET NULL, - selected_drill_page_id TEXT - REFERENCES ${DRILL_SETS_TABLE}(id) ON DELETE SET NULL - ); - - INSERT OR IGNORE INTO ${APP_SETTINGS_TABLE} (singleton_id) - VALUES (1); - `); - await recordMigration(db, MOBILE_SCHEMA_VERSION); - }); -} - -interface LegacySetRow { - readonly id: string; - readonly drill_id: string; - readonly ordinal: number; - readonly label: string; - readonly counts_from_previous: number; - readonly x_meters: number; - readonly y_meters: number; -} - -interface LegacyIdentity { - readonly number: number; - readonly suffix?: string; - readonly kind: "set" | "subset"; +async function rebuildMobileDatabase(db: SQLiteDatabase): Promise { + await db.execAsync("PRAGMA foreign_keys = OFF;"); + try { + await db.withTransactionAsync(async () => { + await db.execAsync(` + DROP TABLE IF EXISTS ${APP_SETTINGS_TABLE}; + DROP TABLE IF EXISTS drill_pages; + DROP TABLE IF EXISTS ${DRILL_SETS_TABLE}; + DROP TABLE IF EXISTS ${DRILLS_TABLE}; + DROP TABLE IF EXISTS mobile_schema_migrations; + `); + await createCurrentSchema(db); + }); + } finally { + await db.execAsync("PRAGMA foreign_keys = ON;"); + } } -async function migrateVersionOneToTwo(db: SQLiteDatabase): Promise { - await db.withTransactionAsync(async () => { - await db.execAsync(` - ALTER TABLE ${DRILLS_TABLE} - ADD COLUMN field_preset TEXT NOT NULL DEFAULT 'football-nfhs'; - - ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN set_number INTEGER; - ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN set_suffix TEXT; - ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN set_kind TEXT; - ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN measure_start INTEGER; - ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN measure_end INTEGER; - ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN x_steps REAL; - ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN y_steps REAL; - ALTER TABLE ${DRILL_SETS_TABLE} ADD COLUMN facing_degrees REAL; - `); - - const rows = await db.getAllAsync( - `SELECT id, drill_id, ordinal, label, counts_from_previous, x_meters, y_meters - FROM ${DRILL_SETS_TABLE} - ORDER BY drill_id ASC, ordinal ASC, id ASC`, - ); - const rowsByDrill = new Map(); - for (const legacy of rows) { - const list = rowsByDrill.get(legacy.drill_id) ?? []; - list.push(legacy); - rowsByDrill.set(legacy.drill_id, list); - } - - for (const drillRows of rowsByDrill.values()) { - await migrateLegacyDrillRows(db, drillRows); - } - - await db.runAsync( - `UPDATE ${APP_SETTINGS_TABLE} SET drill_terminology = 'sets' WHERE singleton_id = 1`, +async function createCurrentSchema(db: SQLiteDatabase): Promise { + await db.execAsync(` + CREATE TABLE ${DRILLS_TABLE} ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + field_preset TEXT NOT NULL DEFAULT 'football-nfhs' + CHECK (field_preset IN (${FIELD_PRESET_SQL_LIST})), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL ); - await recordMigration(db, 2); - }); -} - -async function migrateLegacyDrillRows( - db: SQLiteDatabase, - rows: readonly LegacySetRow[], -): Promise { - const parsed = rows.map((row) => parseLegacySetLabel(row.label)); - const reservedPrimaryNumbers = new Set(); - for (const identity of parsed) { - if (identity?.kind === "set") reservedPrimaryNumbers.add(identity.number); - } - - const usedPrimaryNumbers = new Set(); - const usedIdentities = new Set(); - - for (const [index, row] of rows.entries()) { - const candidate = parsed[index]; - let identity: LegacyIdentity | undefined; - if ( - candidate?.kind === "set" && - !usedPrimaryNumbers.has(candidate.number) - ) { - identity = candidate; - } else if ( - candidate?.kind === "subset" && - reservedPrimaryNumbers.has(candidate.number) && - !usedIdentities.has(identityKey(candidate)) - ) { - identity = candidate; - } - if (!identity) { - let fallback = Math.max(0, row.ordinal + 1); - while ( - reservedPrimaryNumbers.has(fallback) || - usedPrimaryNumbers.has(fallback) - ) { - fallback += 1; - } - identity = { number: fallback, kind: "set" }; - } - - if (identity.kind === "set") usedPrimaryNumbers.add(identity.number); - usedIdentities.add(identityKey(identity)); - - // v1 stored X from the Side-1 goal line. Center it first, then project the - // exact physical position onto the new conventional NFHS marching grid. - const grid = physicalPointToDrillGrid( - { - xMeters: row.x_meters - HALF_FIELD_METERS, - yMeters: row.y_meters, - }, - NFHS_FIELD, + CREATE INDEX idx_drills_created_at + ON ${DRILLS_TABLE}(created_at, id); + + CREATE TABLE ${DRILL_SETS_TABLE} ( + id TEXT PRIMARY KEY NOT NULL, + drill_id TEXT NOT NULL + REFERENCES ${DRILLS_TABLE}(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL + CHECK (ordinal >= 0 AND ordinal = CAST(ordinal AS INTEGER)), + set_number INTEGER NOT NULL CHECK (set_number >= 0), + set_suffix TEXT, + set_kind TEXT NOT NULL CHECK (set_kind IN ('set', 'subset')), + counts_from_previous INTEGER NOT NULL + CHECK ( + counts_from_previous >= 0 AND + counts_from_previous = CAST(counts_from_previous AS INTEGER) + ), + measure_start INTEGER, + measure_end INTEGER, + x_steps REAL NOT NULL CHECK (x_steps = x_steps), + y_steps REAL NOT NULL CHECK (y_steps = y_steps), + facing_degrees REAL + CHECK (facing_degrees IS NULL OR (facing_degrees >= 0 AND facing_degrees < 360)), + CHECK ( + (set_kind = 'set' AND set_suffix IS NULL) OR + (set_kind = 'subset' AND set_suffix IS NOT NULL) + ), + CHECK ( + (measure_start IS NULL AND measure_end IS NULL) OR + (measure_start IS NOT NULL AND measure_end IS NOT NULL AND + measure_start >= 0 AND measure_end >= measure_start) + ), + UNIQUE (drill_id, ordinal), + UNIQUE (drill_id, set_number, set_suffix) ); - const counts = - index === 0 ? 0 : normalizeLegacyCount(row.counts_from_previous); - await db.runAsync( - `UPDATE ${DRILL_SETS_TABLE} - SET set_number = ?, set_suffix = ?, set_kind = ?, counts_from_previous = ?, - x_steps = ?, y_steps = ?, facing_degrees = NULL, - label = ? - WHERE id = ?`, - [ - identity.number, - identity.suffix ?? null, - identity.kind, - counts, - grid.xSteps, - grid.ySteps, - formatSetName(identity), - row.id, - ], + CREATE INDEX idx_drill_sets_drill + ON ${DRILL_SETS_TABLE}(drill_id, ordinal, id); + + CREATE TABLE ${APP_SETTINGS_TABLE} ( + singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), + drill_features_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (drill_features_enabled IN (0, 1)), + drill_terminology TEXT NOT NULL DEFAULT 'sets' + CHECK (drill_terminology = 'sets'), + field_perspective TEXT NOT NULL DEFAULT 'director' + CHECK (field_perspective IN ('director', 'performer')), + default_field_preset TEXT NOT NULL DEFAULT 'football-nfhs' + CHECK (default_field_preset IN (${FIELD_PRESET_SQL_LIST})), + transition_metric_mode TEXT NOT NULL DEFAULT 'step-size' + CHECK (transition_metric_mode IN ('step-size', 'crossing-counts')), + guidance_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (guidance_enabled IN (0, 1)), + developer_mode_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (developer_mode_enabled IN (0, 1)), + show_cached_anchor_geometry INTEGER NOT NULL DEFAULT 0 + CHECK (show_cached_anchor_geometry IN (0, 1)), + show_comfortable_anchor_range INTEGER NOT NULL DEFAULT 0 + CHECK (show_comfortable_anchor_range IN (0, 1)), + show_perimeter_step_grid INTEGER NOT NULL DEFAULT 0 + CHECK (show_perimeter_step_grid IN (0, 1)), + comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 + CHECK (comfortable_anchor_range_meters > 0), + active_drill_id TEXT + REFERENCES ${DRILLS_TABLE}(id) ON DELETE SET NULL, + selected_drill_page_id TEXT + REFERENCES ${DRILL_SETS_TABLE}(id) ON DELETE SET NULL ); - } -} -export function parseLegacySetLabel(label: string): LegacyIdentity | undefined { - const match = label.trim().match(/^([0-9]+)([A-Z]|\.[0-9]+)?$/); - if (!match) return undefined; - const number = Number(match[1]); - if (!Number.isSafeInteger(number)) return undefined; - const suffix = match[2]; - return suffix ? { number, suffix, kind: "subset" } : { number, kind: "set" }; -} - -function identityKey(identity: LegacyIdentity): string { - return `${identity.number}|${identity.suffix ?? ""}`; -} + INSERT INTO ${APP_SETTINGS_TABLE} (singleton_id) VALUES (1); -function normalizeLegacyCount(value: number): number { - if (!Number.isFinite(value) || value < 0) return 0; - return Math.round(value); -} - -async function recordMigration( - db: SQLiteDatabase, - version: number, -): Promise { - await db.runAsync( - `INSERT OR REPLACE INTO ${MOBILE_SCHEMA_MIGRATIONS_TABLE} - (version, applied_at) VALUES (?, ?)`, - [version, Date.now()], - ); - await db.execAsync(`PRAGMA user_version = ${version};`); + PRAGMA user_version = ${MOBILE_SCHEMA_VERSION}; + `); } function parseSchemaVersion(value: number | string | undefined): number { From 0203f4236fd573d8b2bdfcfb4eb7a7b4578df008 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Tue, 4 Aug 2026 00:18:32 -0500 Subject: [PATCH 048/101] feat(mobile): Add drill file upload flow Replace the in-app drill creation flow with an Eight2Five JSON upload route. Validate supported drill documents before importing them into the mobile repository and add coverage for rejected and rollback cases. --- apps/mobile/app/(tabs)/drill/_layout.tsx | 3 +- apps/mobile/app/(tabs)/drill/new.tsx | 6 +- apps/mobile/app/(tabs)/drill/upload.tsx | 5 + .../drill/__tests__/drill-import.test.ts | 209 ++++++++++++++++++ .../drill/components/drill-empty-state.tsx | 14 +- .../features/drill/drill-editor-screen.tsx | 32 +-- .../mobile/src/features/drill/drill-import.ts | 141 ++++++++++++ .../src/features/drill/drill-list-screen.tsx | 166 +++++++------- .../features/drill/drill-upload-screen.tsx | 144 ++++++++++++ .../drill/use-drill-editor-controller.ts | 17 +- 10 files changed, 605 insertions(+), 132 deletions(-) create mode 100644 apps/mobile/app/(tabs)/drill/upload.tsx create mode 100644 apps/mobile/src/features/drill/__tests__/drill-import.test.ts create mode 100644 apps/mobile/src/features/drill/drill-import.ts create mode 100644 apps/mobile/src/features/drill/drill-upload-screen.tsx diff --git a/apps/mobile/app/(tabs)/drill/_layout.tsx b/apps/mobile/app/(tabs)/drill/_layout.tsx index c8f01fb7..aed2a862 100644 --- a/apps/mobile/app/(tabs)/drill/_layout.tsx +++ b/apps/mobile/app/(tabs)/drill/_layout.tsx @@ -29,7 +29,8 @@ export default function DrillLayout() { }} > - + + ; +export default function LegacyNewDrillRoute() { + return ; } diff --git a/apps/mobile/app/(tabs)/drill/upload.tsx b/apps/mobile/app/(tabs)/drill/upload.tsx new file mode 100644 index 00000000..19b2ba8d --- /dev/null +++ b/apps/mobile/app/(tabs)/drill/upload.tsx @@ -0,0 +1,5 @@ +import { DrillUploadScreen } from "../../../src/features/drill/drill-upload-screen"; + +export default function UploadDrillRoute() { + return ; +} diff --git a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts new file mode 100644 index 00000000..99c48eb2 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts @@ -0,0 +1,209 @@ +import type { DrillRepository } from "@eight2five/mobile/drill"; +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + type DrillDocument, +} from "@eight2five/drill-schema"; + +import { + importEight2FiveDrillJson, + isEight2FiveDrillFileName, + parseImportableDrillJson, +} from "../drill-import"; + +const VALID_DOCUMENT: DrillDocument = { + schema: DRILL_SCHEMA_URL, + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: "Part 4 Finale", + createdAt: "2026-08-03T18:00:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entities: [ + { + id: 42, + type: "performer", + symbol: "B", + label: "B1", + }, + ], + sets: [ + { + id: 0, + number: 1, + kind: "set", + countsFromPrevious: 0, + }, + { + id: 1, + number: 2, + kind: "set", + countsFromPrevious: 16, + measureRange: { start: 12, end: 15 }, + }, + ], + positions: [ + { entityId: 42, setId: 0, xSteps: -8, ySteps: 0 }, + { + entityId: 42, + setId: 1, + xSteps: 4, + ySteps: 32, + facingDegrees: 90, + }, + ], +}; + +describe("Eight2Five drill import", () => { + test("recognizes the converter drill file extension", () => { + expect(isEight2FiveDrillFileName("finale.eight2five.json")).toBe(true); + expect(isEight2FiveDrillFileName("EIGHT2FIVE.JSON")).toBe(true); + expect(isEight2FiveDrillFileName("finale.json")).toBe(false); + }); + + test("validates and imports a single-performer portable drill", async () => { + const created = { + id: "drill-1", + name: "Part 4 Finale", + fieldPreset: "football-nfhs" as const, + createdAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), + updatedAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), + }; + const repository = { + createDrill: jest.fn(async () => created), + createSet: jest.fn(async (input) => ({ id: "set", ...input })), + deleteDrill: jest.fn(async () => undefined), + } as unknown as DrillRepository; + + await expect( + importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)), + ).resolves.toBe(created); + + expect(repository.createDrill).toHaveBeenCalledWith({ + name: "Part 4 Finale", + fieldPreset: "football-nfhs", + createdAt: Date.parse("2026-08-03T18:00:00.000Z"), + updatedAt: Date.parse("2026-08-03T18:00:00.000Z"), + }); + expect(repository.createSet).toHaveBeenNthCalledWith(1, { + drillId: "drill-1", + number: 1, + kind: "set", + countsFromPrevious: 0, + position: { xSteps: -8, ySteps: 0 }, + }); + expect(repository.createSet).toHaveBeenNthCalledWith(2, { + drillId: "drill-1", + number: 2, + kind: "set", + countsFromPrevious: 16, + measureRange: { start: 12, end: 15 }, + position: { xSteps: 4, ySteps: 32 }, + facingDegrees: 90, + }); + expect(repository.deleteDrill).not.toHaveBeenCalled(); + }); + + test("rejects unsupported multi-performer documents before writing", () => { + const document: DrillDocument = { + ...VALID_DOCUMENT, + entities: [ + ...VALID_DOCUMENT.entities, + { + id: 43, + type: "performer", + symbol: "B", + label: "B2", + }, + ], + positions: [ + ...VALID_DOCUMENT.positions, + { entityId: 43, setId: 0, xSteps: 0, ySteps: 0 }, + { entityId: 43, setId: 1, xSteps: 8, ySteps: 8 }, + ], + }; + + expect(() => parseImportableDrillJson(JSON.stringify(document))).toThrow( + "exactly one performer", + ); + }); + + test("rejects custom fields and non-straight path geometry", () => { + const customFieldDocument = { + ...VALID_DOCUMENT, + field: { + type: "custom" as const, + name: "Custom", + physicalGeometry: { + bounds: { + minXMeters: 0, + maxXMeters: 10, + minYMeters: 0, + maxYMeters: 10, + }, + referenceLines: [ + { id: "left", name: "Left", axis: "x", coordinateMeters: 0 }, + { id: "right", name: "Right", axis: "x", coordinateMeters: 10 }, + { id: "front", name: "Front", axis: "y", coordinateMeters: 0 }, + { id: "back", name: "Back", axis: "y", coordinateMeters: 10 }, + ], + }, + marchingGrid: { + bounds: { + minXSteps: 0, + maxXSteps: 10, + minYSteps: 0, + maxYSteps: 10, + }, + referenceLines: [ + { id: "left", name: "Left", axis: "x", coordinateSteps: 0 }, + { id: "right", name: "Right", axis: "x", coordinateSteps: 10 }, + { id: "front", name: "Front", axis: "y", coordinateSteps: 0 }, + { id: "back", name: "Back", axis: "y", coordinateSteps: 10 }, + ], + }, + }, + } satisfies DrillDocument; + expect(() => + parseImportableDrillJson(JSON.stringify(customFieldDocument)), + ).toThrow("Custom field definitions"); + + const curvedPathDocument = { + ...VALID_DOCUMENT, + paths: [ + { + entityId: 42, + fromSetId: 0, + toSetId: 1, + kind: "polyline" as const, + waypoints: [{ xSteps: 2, ySteps: 2 }], + }, + ], + } satisfies DrillDocument; + expect(() => + parseImportableDrillJson(JSON.stringify(curvedPathDocument)), + ).toThrow("Polyline and Bézier"); + }); + + test("rolls back a partially-created drill if a set insert fails", async () => { + const repository = { + createDrill: jest.fn(async () => ({ + id: "drill-1", + name: "Part 4 Finale", + fieldPreset: "football-nfhs" as const, + createdAt: 1, + updatedAt: 1, + })), + createSet: jest + .fn() + .mockResolvedValueOnce({ id: "set-1" }) + .mockRejectedValueOnce(new Error("database failed")), + deleteDrill: jest.fn(async () => undefined), + } as unknown as DrillRepository; + + await expect( + importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)), + ).rejects.toThrow("database failed"); + expect(repository.deleteDrill).toHaveBeenCalledWith("drill-1"); + }); +}); diff --git a/apps/mobile/src/features/drill/components/drill-empty-state.tsx b/apps/mobile/src/features/drill/components/drill-empty-state.tsx index 3c389827..d8549594 100644 --- a/apps/mobile/src/features/drill/components/drill-empty-state.tsx +++ b/apps/mobile/src/features/drill/components/drill-empty-state.tsx @@ -1,4 +1,4 @@ -import { NotebookTabs, Plus } from "lucide-react-native"; +import { FileUp, NotebookTabs } from "lucide-react-native"; import type { DrillTerms } from "@eight2five/mobile/drill"; import { Button, @@ -18,10 +18,10 @@ import { export function DrillEmptyState({ terms, - onCreate, + onUpload, }: { terms: DrillTerms; - onCreate(): void; + onUpload(): void; }) { const theme = useEight2FiveTheme(); return ( @@ -38,12 +38,12 @@ export function DrillEmptyState({ No drills yet - Drills are entered manually. Create one to start adding{" "} + Upload an Eight2Five drill file to start working with its{" "} {terms.lowercasePlural}. - diff --git a/apps/mobile/src/features/drill/drill-editor-screen.tsx b/apps/mobile/src/features/drill/drill-editor-screen.tsx index 4280a9de..aa6758eb 100644 --- a/apps/mobile/src/features/drill/drill-editor-screen.tsx +++ b/apps/mobile/src/features/drill/drill-editor-screen.tsx @@ -11,7 +11,6 @@ import { import { Card } from "@eight2five/ui/components/card"; import { FlatList } from "@eight2five/ui/components/flat-list"; import { Heading } from "@eight2five/ui/components/heading"; -import { ScrollView } from "@eight2five/ui/components/scroll-view"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { @@ -29,10 +28,9 @@ import { } from "./components/drill-page-actions"; import { DrillPageListItem } from "./components/drill-page-list-item"; import { DrillNameDialog } from "./components/drill-name-dialog"; -import { DrillNameForm } from "./components/drill-name-form"; import { useDrillEditorController } from "./use-drill-editor-controller"; -export function DrillEditorScreen({ drillId }: { drillId?: string }) { +export function DrillEditorScreen({ drillId }: { drillId: string }) { const router = useRouter(); const theme = useEight2FiveTheme(); const controller = useDrillEditorController(drillId); @@ -74,34 +72,6 @@ export function DrillEditorScreen({ drillId }: { drillId?: string }) { [controller, openPage], ); - if (!drillId) { - return ( - - Create Drill - - Name the drill before entering {controller.terms.lowercasePlural}. - - { - const created = await controller.saveName(name); - router.replace(`/(tabs)/drill/${created.id}`); - }} - /> - - ); - } - const drill = controller.drill; const deleteDrill = () => { if (!drill) return; diff --git a/apps/mobile/src/features/drill/drill-import.ts b/apps/mobile/src/features/drill/drill-import.ts new file mode 100644 index 00000000..a76d1f55 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-import.ts @@ -0,0 +1,141 @@ +import type { Drill, DrillRepository } from "@eight2five/mobile/drill"; +import { + safeParseDrillDocument, + type DrillDocument, +} from "@eight2five/drill-schema"; + +export const EIGHT2FIVE_DRILL_FILE_SUFFIX = ".eight2five.json"; +export const MAX_DRILL_UPLOAD_BYTES = 10 * 1024 * 1024; + +export function isEight2FiveDrillFileName(fileName: string): boolean { + const normalized = fileName.trim().toLowerCase(); + return ( + normalized === "eight2five.json" || + normalized.endsWith(EIGHT2FIVE_DRILL_FILE_SUFFIX) + ); +} + +export function parseImportableDrillJson(json: string): DrillDocument { + let value: unknown; + try { + value = JSON.parse(json) as unknown; + } catch { + throw new Error("The selected file is not valid JSON."); + } + + const parsed = safeParseDrillDocument(value); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const detail = issue?.message ? ` ${issue.message}` : ""; + throw new Error(`This is not a valid Eight2Five drill file.${detail}`); + } + + assertMobileImportSupport(parsed.data); + return parsed.data; +} + +export async function importEight2FiveDrillJson( + repository: DrillRepository, + json: string, +): Promise { + return await importEight2FiveDrillDocument( + repository, + parseImportableDrillJson(json), + ); +} + +export async function importEight2FiveDrillDocument( + repository: DrillRepository, + document: DrillDocument, +): Promise { + assertMobileImportSupport(document); + + const fieldPreset = document.field.preset; + const performer = document.entities[0]; + const positionsBySet = new Map( + document.positions + .filter((position) => position.entityId === performer.id) + .map((position) => [position.setId, position] as const), + ); + const createdAt = Date.parse(document.metadata.createdAt); + + const drill = await repository.createDrill({ + name: document.metadata.title, + fieldPreset, + createdAt, + updatedAt: createdAt, + }); + + try { + for (const set of document.sets) { + const position = positionsBySet.get(set.id); + if (!position) { + throw new Error( + `Set ${set.number}${set.suffix ?? ""} is missing the performer's position.`, + ); + } + + await repository.createSet({ + drillId: drill.id, + number: set.number, + ...(set.suffix !== undefined ? { suffix: set.suffix } : {}), + kind: set.kind, + countsFromPrevious: set.countsFromPrevious, + ...(set.measureRange ? { measureRange: set.measureRange } : {}), + position: { + xSteps: position.xSteps, + ySteps: position.ySteps, + }, + ...(position.facingDegrees !== undefined + ? { facingDegrees: position.facingDegrees } + : {}), + }); + } + } catch (cause) { + try { + await repository.deleteDrill(drill.id); + } catch { + // Preserve the original import failure. The repository normally cascades + // drill deletion to any sets already inserted. + } + throw cause; + } + + return drill; +} + +function assertMobileImportSupport( + document: DrillDocument, +): asserts document is DrillDocument & { + readonly field: Extract; + readonly entities: readonly [DrillDocument["entities"][number]]; +} { + if (document.field.type !== "preset") { + throw new Error( + "Custom field definitions are not supported in the mobile app yet.", + ); + } + + if ( + document.entities.length !== 1 || + document.entities[0]?.type !== "performer" + ) { + throw new Error( + "The mobile app currently supports drill files with exactly one performer and no props.", + ); + } + + const performerId = document.entities[0].id; + const performerPositions = document.positions.filter( + (position) => position.entityId === performerId, + ); + if (performerPositions.length !== document.sets.length) { + throw new Error("Every set must include a position for the performer."); + } + + if (document.paths?.some((path) => path.kind !== "straight")) { + throw new Error( + "Polyline and Bézier drill paths are not supported in the mobile app yet.", + ); + } +} diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx index dbde652f..93d7ee30 100644 --- a/apps/mobile/src/features/drill/drill-list-screen.tsx +++ b/apps/mobile/src/features/drill/drill-list-screen.tsx @@ -1,14 +1,11 @@ import React from "react"; -import { useRouter } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { Plus } from "lucide-react-native"; import type { Drill } from "@eight2five/mobile/drill"; -import { - Button, - ButtonIcon, - ButtonText, -} from "@eight2five/ui/components/button"; import { FlatList } from "@eight2five/ui/components/flat-list"; import { Heading } from "@eight2five/ui/components/heading"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { @@ -73,82 +70,95 @@ export function DrillListScreen() { }; return ( - - entry.drill.id} - renderItem={renderItem} - contentInsetAdjustmentBehavior="automatic" - contentContainerStyle={{ - flexGrow: 1, - gap: eight2FiveSpacing.sm, - padding: eight2FiveSpacing.md, - paddingBottom: eight2FiveSpacing.xxl, - }} - ListHeaderComponent={ - - + ( + router.push("/(tabs)/drill/upload")} + accessibilityRole="button" + accessibilityLabel="Upload Drill" + hitSlop={8} style={{ - color: theme.text, - fontFamily: eight2FiveFonts.styleBold, + width: 40, + height: 40, + alignItems: "center", + justifyContent: "center", }} > - Drills - - {controller.entries.length > 0 ? ( - - ) : null} - {controller.loading ? ( - Loading drills… - ) : null} - {controller.error ? ( - - {controller.error.message} - - ) : null} - - } - ListEmptyComponent={ - controller.loading ? null : ( - router.push("/(tabs)/drill/new")} - /> - ) - } - /> - - setActionDrill(undefined)} - onMakeActive={() => { - const drill = actionDrill; - setActionDrill(undefined); - if (drill) { - void controller.makeActive(drill).catch(() => undefined); - } + + + ), }} - onRename={beginRename} - onDelete={beginDelete} /> - setRenameDrill(undefined)} - onSave={async (name) => { - if (!renameDrill) return; - await controller.rename(renameDrill, name); - setRenameDrill(undefined); - }} - /> - + + entry.drill.id} + renderItem={renderItem} + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ + flexGrow: 1, + gap: eight2FiveSpacing.sm, + padding: eight2FiveSpacing.md, + paddingBottom: eight2FiveSpacing.xxl, + }} + ListHeaderComponent={ + + + Drills + + {controller.loading ? ( + Loading drills… + ) : null} + {controller.error ? ( + + {controller.error.message} + + ) : null} + + } + ListEmptyComponent={ + controller.loading ? null : ( + router.push("/(tabs)/drill/upload")} + /> + ) + } + /> + + setActionDrill(undefined)} + onMakeActive={() => { + const drill = actionDrill; + setActionDrill(undefined); + if (drill) { + void controller.makeActive(drill).catch(() => undefined); + } + }} + onRename={beginRename} + onDelete={beginDelete} + /> + setRenameDrill(undefined)} + onSave={async (name) => { + if (!renameDrill) return; + await controller.rename(renameDrill, name); + setRenameDrill(undefined); + }} + /> + + ); } diff --git a/apps/mobile/src/features/drill/drill-upload-screen.tsx b/apps/mobile/src/features/drill/drill-upload-screen.tsx new file mode 100644 index 00000000..c3279263 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-upload-screen.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import * as DocumentPicker from "expo-document-picker"; +import { File } from "expo-file-system"; +import { useRouter } from "expo-router"; +import { FileUp } from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Card } from "@eight2five/ui/components/card"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { Text } from "@eight2five/ui/components/text"; +import { + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { SettingsMessage } from "../settings/settings-components"; +import { + EIGHT2FIVE_DRILL_FILE_SUFFIX, + MAX_DRILL_UPLOAD_BYTES, + importEight2FiveDrillJson, + isEight2FiveDrillFileName, +} from "./drill-import"; +import { toError } from "./drill-management"; + +export function DrillUploadScreen() { + const router = useRouter(); + const theme = useEight2FiveTheme(); + const snapshot = useAppSettingsSnapshot(); + const store = useAppSettingsStore(); + const [importing, setImporting] = React.useState(false); + const [selectedFileName, setSelectedFileName] = React.useState(); + const [error, setError] = React.useState(); + + const selectFile = React.useCallback(async () => { + if (snapshot.status !== "ready" || importing) return; + + setError(undefined); + try { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + type: ["application/json", "text/json"], + }); + if (result.canceled) return; + + const asset = result.assets[0]; + if (!asset) return; + + setSelectedFileName(asset.name); + setImporting(true); + if (!isEight2FiveDrillFileName(asset.name)) { + throw new Error( + `Select a file ending in ${EIGHT2FIVE_DRILL_FILE_SUFFIX}.`, + ); + } + if ( + typeof asset.size === "number" && + asset.size > MAX_DRILL_UPLOAD_BYTES + ) { + throw new Error("The selected drill file is too large to import."); + } + + const json = await new File(asset.uri).text(); + if (json.length > MAX_DRILL_UPLOAD_BYTES) { + throw new Error("The selected drill file is too large to import."); + } + + const drill = await importEight2FiveDrillJson( + store.getDrillRepository(), + json, + ); + router.replace(`/(tabs)/drill/${drill.id}`); + } catch (cause) { + setError(toError(cause)); + } finally { + setImporting(false); + } + }, [importing, router, snapshot.status, store]); + + return ( + + + + Upload an Eight2Five drill file exported as{" "} + + *{EIGHT2FIVE_DRILL_FILE_SUFFIX} + + . + + + The mobile app currently imports one performer on a preset football + field. Multi-performer drills, props, custom fields, and non-straight + path geometry are not supported yet. + + + + {selectedFileName ? ( + + Selected: {selectedFileName} + + ) : null} + + {error ? ( + {error.message} + ) : null} + + + + ); +} diff --git a/apps/mobile/src/features/drill/use-drill-editor-controller.ts b/apps/mobile/src/features/drill/use-drill-editor-controller.ts index 1aa7f3e0..d8ee6c39 100644 --- a/apps/mobile/src/features/drill/use-drill-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-editor-controller.ts @@ -11,7 +11,6 @@ import { useAppSettingsStore, } from "../../state/app-settings-store"; import { - createNamedDrill, deleteDrillAndRefreshSettings, renameNamedDrill, toError, @@ -22,19 +21,19 @@ import { type PageMoveDirection, } from "./page-management"; -export function useDrillEditorController(drillId?: string) { +export function useDrillEditorController(drillId: string) { const snapshot = useAppSettingsSnapshot(); const store = useAppSettingsStore(); const [drill, setDrill] = React.useState(); const [pages, setPages] = React.useState([]); - const [loading, setLoading] = React.useState(Boolean(drillId)); + const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [busyPageId, setBusyPageId] = React.useState(); const [error, setError] = React.useState(); const operationInFlight = React.useRef(false); const refresh = React.useCallback(async () => { - if (!drillId || snapshot.status !== "ready") return; + if (snapshot.status !== "ready") return; try { const repository = store.getDrillRepository(); const [nextDrill, nextPages] = await Promise.all([ @@ -68,13 +67,7 @@ export function useDrillEditorController(drillId?: string) { setError(undefined); try { const repository = store.getDrillRepository(); - const saved = drillId - ? await renameNamedDrill(repository, drillId, name) - : await createNamedDrill( - repository, - name, - snapshot.settings.defaultFieldPreset, - ); + const saved = await renameNamedDrill(repository, drillId, name); setDrill(saved); return saved; } catch (cause) { @@ -86,7 +79,7 @@ export function useDrillEditorController(drillId?: string) { setSaving(false); } }, - [drillId, snapshot.settings.defaultFieldPreset, store], + [drillId, store], ); const makeActive = React.useCallback(async () => { From fa0b15d9c256ba1751dc1ed40888d1f0fa4256a1 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Tue, 4 Aug 2026 00:47:53 -0500 Subject: [PATCH 049/101] feat(mobile): Support multi-performer drill imports Allow uploaded drill files to contain multiple performers and props. Prompt the user to select a performer dot before importing that performer's coordinates into the mobile drill model. --- .../drill/__tests__/drill-import.test.ts | 169 +++++++++---- .../components/performer-selection-dialog.tsx | 234 ++++++++++++++++++ .../mobile/src/features/drill/drill-import.ts | 91 +++++-- .../features/drill/drill-upload-screen.tsx | 160 +++++++----- 4 files changed, 535 insertions(+), 119 deletions(-) create mode 100644 apps/mobile/src/features/drill/components/performer-selection-dialog.tsx diff --git a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts index 99c48eb2..156a35fb 100644 --- a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts +++ b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts @@ -6,6 +6,8 @@ import { } from "@eight2five/drill-schema"; import { + getPerformerSymbolGroups, + importEight2FiveDrillDocument, importEight2FiveDrillJson, isEight2FiveDrillFileName, parseImportableDrillJson, @@ -54,6 +56,41 @@ const VALID_DOCUMENT: DrillDocument = { ], }; +const MULTI_ENTITY_DOCUMENT: DrillDocument = { + ...VALID_DOCUMENT, + entities: [ + { id: 42, type: "performer", symbol: "B", label: "B1" }, + { id: 43, type: "performer", symbol: "B", label: "B2" }, + { id: 44, type: "performer", symbol: "T", label: "T1" }, + { id: 99, type: "prop", symbol: "P", label: "P1" }, + ], + positions: [ + ...VALID_DOCUMENT.positions, + { entityId: 43, setId: 0, xSteps: 10, ySteps: 12 }, + { entityId: 43, setId: 1, xSteps: 14, ySteps: 20 }, + { entityId: 44, setId: 0, xSteps: -4, ySteps: 8 }, + { entityId: 44, setId: 1, xSteps: -2, ySteps: 10 }, + { entityId: 99, setId: 0, xSteps: 0, ySteps: 16 }, + { entityId: 99, setId: 1, xSteps: 0, ySteps: 18 }, + ], +}; + +function createRepository() { + const created = { + id: "drill-1", + name: "Part 4 Finale", + fieldPreset: "football-nfhs" as const, + createdAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), + updatedAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), + }; + const repository = { + createDrill: jest.fn(async () => created), + createSet: jest.fn(async (input) => ({ id: "set", ...input })), + deleteDrill: jest.fn(async () => undefined), + } as unknown as DrillRepository; + return { created, repository }; +} + describe("Eight2Five drill import", () => { test("recognizes the converter drill file extension", () => { expect(isEight2FiveDrillFileName("finale.eight2five.json")).toBe(true); @@ -62,18 +99,7 @@ describe("Eight2Five drill import", () => { }); test("validates and imports a single-performer portable drill", async () => { - const created = { - id: "drill-1", - name: "Part 4 Finale", - fieldPreset: "football-nfhs" as const, - createdAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), - updatedAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), - }; - const repository = { - createDrill: jest.fn(async () => created), - createSet: jest.fn(async (input) => ({ id: "set", ...input })), - deleteDrill: jest.fn(async () => undefined), - } as unknown as DrillRepository; + const { created, repository } = createRepository(); await expect( importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)), @@ -104,31 +130,92 @@ describe("Eight2Five drill import", () => { expect(repository.deleteDrill).not.toHaveBeenCalled(); }); - test("rejects unsupported multi-performer documents before writing", () => { - const document: DrillDocument = { - ...VALID_DOCUMENT, - entities: [ - ...VALID_DOCUMENT.entities, + test("accepts multi-performer files with props and groups selectable performers by symbol", () => { + const parsed = parseImportableDrillJson( + JSON.stringify(MULTI_ENTITY_DOCUMENT), + ); + + expect(getPerformerSymbolGroups(parsed)).toEqual([ + { + symbol: "B", + performers: [ + expect.objectContaining({ id: 42, label: "B1" }), + expect.objectContaining({ id: 43, label: "B2" }), + ], + }, + { + symbol: "T", + performers: [expect.objectContaining({ id: 44, label: "T1" })], + }, + ]); + }); + + test("imports only the coordinates for the performer selected from a multi-performer file", async () => { + const { repository } = createRepository(); + + await importEight2FiveDrillDocument(repository, MULTI_ENTITY_DOCUMENT, 43); + + expect(repository.createSet).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + position: { xSteps: 10, ySteps: 12 }, + }), + ); + expect(repository.createSet).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + position: { xSteps: 14, ySteps: 20 }, + }), + ); + }); + + test("requires an explicit performer selection when a file has multiple performers", async () => { + const { repository } = createRepository(); + + await expect( + importEight2FiveDrillDocument(repository, MULTI_ENTITY_DOCUMENT), + ).rejects.toThrow("Select your performer"); + expect(repository.createDrill).not.toHaveBeenCalled(); + }); + + test("allows unsupported path geometry on other entities but rejects it for the selected performer", async () => { + const otherEntityCurved: DrillDocument = { + ...MULTI_ENTITY_DOCUMENT, + paths: [ { - id: 43, - type: "performer", - symbol: "B", - label: "B2", + entityId: 99, + fromSetId: 0, + toSetId: 1, + kind: "polyline", + waypoints: [{ xSteps: 2, ySteps: 2 }], }, ], - positions: [ - ...VALID_DOCUMENT.positions, - { entityId: 43, setId: 0, xSteps: 0, ySteps: 0 }, - { entityId: 43, setId: 1, xSteps: 8, ySteps: 8 }, - ], }; + const firstRepository = createRepository().repository; + await expect( + importEight2FiveDrillDocument(firstRepository, otherEntityCurved, 43), + ).resolves.toBeDefined(); - expect(() => parseImportableDrillJson(JSON.stringify(document))).toThrow( - "exactly one performer", - ); + const selectedCurved: DrillDocument = { + ...MULTI_ENTITY_DOCUMENT, + paths: [ + { + entityId: 43, + fromSetId: 0, + toSetId: 1, + kind: "polyline", + waypoints: [{ xSteps: 12, ySteps: 16 }], + }, + ], + }; + const secondRepository = createRepository().repository; + await expect( + importEight2FiveDrillDocument(secondRepository, selectedCurved, 43), + ).rejects.toThrow("polyline or Bézier"); + expect(secondRepository.createDrill).not.toHaveBeenCalled(); }); - test("rejects custom fields and non-straight path geometry", () => { + test("rejects custom fields and files without any performers", () => { const customFieldDocument = { ...VALID_DOCUMENT, field: { @@ -168,21 +255,17 @@ describe("Eight2Five drill import", () => { parseImportableDrillJson(JSON.stringify(customFieldDocument)), ).toThrow("Custom field definitions"); - const curvedPathDocument = { + const propsOnly: DrillDocument = { ...VALID_DOCUMENT, - paths: [ - { - entityId: 42, - fromSetId: 0, - toSetId: 1, - kind: "polyline" as const, - waypoints: [{ xSteps: 2, ySteps: 2 }], - }, + entities: [{ id: 99, type: "prop", symbol: "P", label: "P1" }], + positions: [ + { entityId: 99, setId: 0, xSteps: 0, ySteps: 0 }, + { entityId: 99, setId: 1, xSteps: 0, ySteps: 8 }, ], - } satisfies DrillDocument; - expect(() => - parseImportableDrillJson(JSON.stringify(curvedPathDocument)), - ).toThrow("Polyline and Bézier"); + }; + expect(() => parseImportableDrillJson(JSON.stringify(propsOnly))).toThrow( + "does not contain a performer", + ); }); test("rolls back a partially-created drill if a set insert fails", async () => { diff --git a/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx new file mode 100644 index 00000000..73a2df06 --- /dev/null +++ b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { useWindowDimensions } from "react-native"; +import type { DrillDocument, DrillEntity } from "@eight2five/drill-schema"; +import { + Button, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Heading } from "@eight2five/ui/components/heading"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { + Modal, + ModalBackdrop, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@eight2five/ui/components/modal"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { SettingsMessage } from "../../settings/settings-components"; +import { getPerformerSymbolGroups } from "../drill-import"; + +interface PerformerSelectionDialogProps { + readonly document?: DrillDocument; + readonly isOpen: boolean; + readonly importing: boolean; + readonly error?: Error; + readonly onClose: () => void; + readonly onConfirm: (performerEntityId: number) => Promise; +} + +export function PerformerSelectionDialog(props: PerformerSelectionDialogProps) { + if (!props.document) return null; + return ( + + ); +} + +function PerformerSelectionDialogContent({ + document, + isOpen, + importing, + error, + onClose, + onConfirm, +}: PerformerSelectionDialogProps & { readonly document: DrillDocument }) { + const theme = useEight2FiveTheme(); + const { height } = useWindowDimensions(); + const groups = React.useMemo( + () => getPerformerSymbolGroups(document), + [document], + ); + const [selectedSymbol, setSelectedSymbol] = React.useState(groups[0]?.symbol); + const [selectedPerformer, setSelectedPerformer] = + React.useState(); + const visiblePerformers = + groups.find((group) => group.symbol === selectedSymbol)?.performers ?? []; + const listHeight = Math.min(380, Math.max(230, height * 0.45)); + + return ( + { + if (!importing) onClose(); + }} + size="lg" + > + + + + Select your dot + + + + + Choose your performer symbol on the left, then select your label + on the right. Props may be present in the uploaded file, but they + are not selectable as your dot. + + + {error ? ( + {error.message} + ) : null} + + + + + + {groups.map((group) => { + const selected = group.symbol === selectedSymbol; + return ( + { + if (importing) return; + setSelectedSymbol(group.symbol); + setSelectedPerformer(undefined); + }} + style={{ + minHeight: 44, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: eight2FiveSpacing.sm, + borderRadius: eight2FiveRadii.sm, + backgroundColor: selected + ? theme.accent + : theme.surfaceRaised, + }} + > + + {group.symbol} + + + ); + })} + + + + + + + + {visiblePerformers.map((performer) => { + const selected = performer.id === selectedPerformer?.id; + return ( + { + if (!importing) setSelectedPerformer(performer); + }} + style={{ + minHeight: 48, + justifyContent: "center", + paddingHorizontal: eight2FiveSpacing.md, + paddingVertical: eight2FiveSpacing.sm, + borderRadius: eight2FiveRadii.sm, + backgroundColor: selected + ? theme.accent + : theme.surfaceRaised, + }} + > + + {performer.label} + + {performer.name ? ( + + {performer.name} + + ) : null} + + ); + })} + + + + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/drill/drill-import.ts b/apps/mobile/src/features/drill/drill-import.ts index a76d1f55..1cc0bfb0 100644 --- a/apps/mobile/src/features/drill/drill-import.ts +++ b/apps/mobile/src/features/drill/drill-import.ts @@ -2,11 +2,17 @@ import type { Drill, DrillRepository } from "@eight2five/mobile/drill"; import { safeParseDrillDocument, type DrillDocument, + type DrillEntity, } from "@eight2five/drill-schema"; export const EIGHT2FIVE_DRILL_FILE_SUFFIX = ".eight2five.json"; export const MAX_DRILL_UPLOAD_BYTES = 10 * 1024 * 1024; +export interface PerformerSymbolGroup { + readonly symbol: string; + readonly performers: readonly DrillEntity[]; +} + export function isEight2FiveDrillFileName(fileName: string): boolean { const normalized = fileName.trim().toLowerCase(); return ( @@ -30,28 +36,48 @@ export function parseImportableDrillJson(json: string): DrillDocument { throw new Error(`This is not a valid Eight2Five drill file.${detail}`); } - assertMobileImportSupport(parsed.data); + assertMobileDocumentSupport(parsed.data); return parsed.data; } +export function getPerformerSymbolGroups( + document: DrillDocument, +): readonly PerformerSymbolGroup[] { + const groups = new Map(); + for (const entity of document.entities) { + if (entity.type !== "performer") continue; + const performers = groups.get(entity.symbol); + if (performers) performers.push(entity); + else groups.set(entity.symbol, [entity]); + } + return [...groups.entries()].map(([symbol, performers]) => ({ + symbol, + performers, + })); +} + export async function importEight2FiveDrillJson( repository: DrillRepository, json: string, + performerEntityId?: number, ): Promise { return await importEight2FiveDrillDocument( repository, parseImportableDrillJson(json), + performerEntityId, ); } export async function importEight2FiveDrillDocument( repository: DrillRepository, document: DrillDocument, + performerEntityId?: number, ): Promise { - assertMobileImportSupport(document); + assertMobileDocumentSupport(document); + const performer = resolveSelectedPerformer(document, performerEntityId); + assertSelectedPerformerSupport(document, performer); const fieldPreset = document.field.preset; - const performer = document.entities[0]; const positionsBySet = new Map( document.positions .filter((position) => position.entityId === performer.id) @@ -71,7 +97,7 @@ export async function importEight2FiveDrillDocument( const position = positionsBySet.get(set.id); if (!position) { throw new Error( - `Set ${set.number}${set.suffix ?? ""} is missing the performer's position.`, + `Set ${set.number}${set.suffix ?? ""} is missing ${performer.label}'s position.`, ); } @@ -104,11 +130,10 @@ export async function importEight2FiveDrillDocument( return drill; } -function assertMobileImportSupport( +function assertMobileDocumentSupport( document: DrillDocument, ): asserts document is DrillDocument & { readonly field: Extract; - readonly entities: readonly [DrillDocument["entities"][number]]; } { if (document.field.type !== "preset") { throw new Error( @@ -116,26 +141,56 @@ function assertMobileImportSupport( ); } - if ( - document.entities.length !== 1 || - document.entities[0]?.type !== "performer" - ) { + if (!document.entities.some((entity) => entity.type === "performer")) { + throw new Error("This drill file does not contain a performer to select."); + } +} + +function resolveSelectedPerformer( + document: DrillDocument, + performerEntityId?: number, +): DrillEntity { + const performers = document.entities.filter( + (entity) => entity.type === "performer", + ); + if (performerEntityId === undefined) { + if (performers.length === 1) return performers[0]; + throw new Error("Select your performer before importing this drill."); + } + + const performer = performers.find( + (entity) => entity.id === performerEntityId, + ); + if (!performer) { throw new Error( - "The mobile app currently supports drill files with exactly one performer and no props.", + "The selected performer is not present in this drill file.", ); } + return performer; +} - const performerId = document.entities[0].id; - const performerPositions = document.positions.filter( - (position) => position.entityId === performerId, +function assertSelectedPerformerSupport( + document: DrillDocument, + performer: DrillEntity, +): void { + const positionedSetIds = new Set( + document.positions + .filter((position) => position.entityId === performer.id) + .map((position) => position.setId), ); - if (performerPositions.length !== document.sets.length) { - throw new Error("Every set must include a position for the performer."); + if (document.sets.some((set) => !positionedSetIds.has(set.id))) { + throw new Error( + `Every set must include a position for ${performer.label}.`, + ); } - if (document.paths?.some((path) => path.kind !== "straight")) { + if ( + document.paths?.some( + (path) => path.entityId === performer.id && path.kind !== "straight", + ) + ) { throw new Error( - "Polyline and Bézier drill paths are not supported in the mobile app yet.", + `${performer.label} uses polyline or Bézier drill paths, which are not supported in the mobile app yet.`, ); } } diff --git a/apps/mobile/src/features/drill/drill-upload-screen.tsx b/apps/mobile/src/features/drill/drill-upload-screen.tsx index c3279263..1ca41fc2 100644 --- a/apps/mobile/src/features/drill/drill-upload-screen.tsx +++ b/apps/mobile/src/features/drill/drill-upload-screen.tsx @@ -3,6 +3,7 @@ import * as DocumentPicker from "expo-document-picker"; import { File } from "expo-file-system"; import { useRouter } from "expo-router"; import { FileUp } from "lucide-react-native"; +import type { DrillDocument } from "@eight2five/drill-schema"; import { Button, ButtonIcon, @@ -23,11 +24,13 @@ import { useAppSettingsStore, } from "../../state/app-settings-store"; import { SettingsMessage } from "../settings/settings-components"; +import { PerformerSelectionDialog } from "./components/performer-selection-dialog"; import { EIGHT2FIVE_DRILL_FILE_SUFFIX, MAX_DRILL_UPLOAD_BYTES, - importEight2FiveDrillJson, + importEight2FiveDrillDocument, isEight2FiveDrillFileName, + parseImportableDrillJson, } from "./drill-import"; import { toError } from "./drill-management"; @@ -36,12 +39,13 @@ export function DrillUploadScreen() { const theme = useEight2FiveTheme(); const snapshot = useAppSettingsSnapshot(); const store = useAppSettingsStore(); - const [importing, setImporting] = React.useState(false); + const [busy, setBusy] = React.useState(false); const [selectedFileName, setSelectedFileName] = React.useState(); + const [pendingDocument, setPendingDocument] = React.useState(); const [error, setError] = React.useState(); const selectFile = React.useCallback(async () => { - if (snapshot.status !== "ready" || importing) return; + if (snapshot.status !== "ready" || busy) return; setError(undefined); try { @@ -56,7 +60,7 @@ export function DrillUploadScreen() { if (!asset) return; setSelectedFileName(asset.name); - setImporting(true); + setBusy(true); if (!isEight2FiveDrillFileName(asset.name)) { throw new Error( `Select a file ending in ${EIGHT2FIVE_DRILL_FILE_SUFFIX}.`, @@ -74,71 +78,111 @@ export function DrillUploadScreen() { throw new Error("The selected drill file is too large to import."); } - const drill = await importEight2FiveDrillJson( - store.getDrillRepository(), - json, - ); - router.replace(`/(tabs)/drill/${drill.id}`); + setPendingDocument(parseImportableDrillJson(json)); } catch (cause) { + setPendingDocument(undefined); setError(toError(cause)); } finally { - setImporting(false); + setBusy(false); } - }, [importing, router, snapshot.status, store]); + }, [busy, snapshot.status]); + + const importSelectedPerformer = React.useCallback( + async (performerEntityId: number) => { + if (!pendingDocument || snapshot.status !== "ready" || busy) return; + setBusy(true); + setError(undefined); + try { + const drill = await importEight2FiveDrillDocument( + store.getDrillRepository(), + pendingDocument, + performerEntityId, + ); + setPendingDocument(undefined); + router.replace(`/(tabs)/drill/${drill.id}`); + } catch (cause) { + setError(toError(cause)); + } finally { + setBusy(false); + } + }, + [busy, pendingDocument, router, snapshot.status, store], + ); + + const closePerformerSelection = React.useCallback(() => { + if (busy) return; + setPendingDocument(undefined); + setError(undefined); + }, [busy]); return ( - - + - - Upload an Eight2Five drill file exported as{" "} - - *{EIGHT2FIVE_DRILL_FILE_SUFFIX} + + + Upload an Eight2Five drill file exported as{" "} + + *{EIGHT2FIVE_DRILL_FILE_SUFFIX} + + . + + + Multi-performer files and props are supported. After selecting a + file, choose the performer dot whose coordinates you want this app + to use. - . - - - The mobile app currently imports one performer on a preset football - field. Multi-performer drills, props, custom fields, and non-straight - path geometry are not supported yet. - - + - {selectedFileName ? ( - - Selected: {selectedFileName} - - ) : null} + {selectedFileName ? ( + + Selected: {selectedFileName} + + ) : null} - {error ? ( - {error.message} - ) : null} + {error && !pendingDocument ? ( + {error.message} + ) : null} - - + + + + + ); } From 276611f44e01177d91d08a0e99cd6cd3ee60a73b Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Tue, 4 Aug 2026 01:06:07 -0500 Subject: [PATCH 050/101] chore: update android foreground images to be visually consistent with iOS icons --- .../mobile-android-adaptive-foreground.png | Bin 94396 -> 51352 bytes .../mobile-android-adaptive-monochrome.png | Bin 85550 -> 46252 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png b/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png index 8d0ccaecd8c213abe74549370ec7091b0aa747d2..d367765d063df06fc0844ef1a647d9373d6de499 100644 GIT binary patch literal 51352 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

fV1h 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

ZBPDzN`F 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 ROJU%jfsU#6^V3ej{|C{c+U@`V From 6dfcbcf3fb6a3ad9ad0319f8ddbb6924c7841a40 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Tue, 4 Aug 2026 01:09:22 -0500 Subject: [PATCH 051/101] fix(mobile): Enforce portrait orientation outside Field Apply route-level screen orientation locks so native tab transitions cannot leave Drill or Settings in landscape. Keep Field on the platform default so it can rotate. --- apps/mobile/app/_layout.tsx | 2 ++ .../__tests__/mobile-orientation-lock.test.ts | 23 +++++++++++++++++++ .../src/navigation/mobile-orientation.ts | 11 +++++++++ .../navigation/use-mobile-orientation-lock.ts | 14 +++++++++++ 4 files changed, 50 insertions(+) create mode 100644 apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts create mode 100644 apps/mobile/src/navigation/mobile-orientation.ts create mode 100644 apps/mobile/src/navigation/use-mobile-orientation-lock.ts diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index ef7f79bf..d94ab4ad 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -9,6 +9,7 @@ import { GluestackUIProvider } from "@eight2five/ui/components/gluestack-ui-prov import { useEight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; import { TabBarVisibilityProvider } from "../src/navigation/tab-bar-visibility-context"; +import { useMobileOrientationLock } from "../src/navigation/use-mobile-orientation-lock"; import { AppSettingsProvider, useAppSettingsSnapshot, @@ -48,6 +49,7 @@ export default function MobileRootLayout() { } function MobileNavigation({ backgroundColor }: { backgroundColor: string }) { + useMobileOrientationLock(); const { settings } = useAppSettingsSnapshot(); const colorScheme = useColorScheme(); diff --git a/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts b/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts new file mode 100644 index 00000000..8bbb404e --- /dev/null +++ b/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts @@ -0,0 +1,23 @@ +import * as ScreenOrientation from "expo-screen-orientation"; + +import { getMobileOrientationLock } from "../mobile-orientation"; + +describe("mobile route orientation", () => { + test("allows device rotation only on Field routes", () => { + expect(getMobileOrientationLock("/field")).toBe( + ScreenOrientation.OrientationLock.DEFAULT, + ); + expect(getMobileOrientationLock("/field/details")).toBe( + ScreenOrientation.OrientationLock.DEFAULT, + ); + }); + + test.each(["/", "/drill", "/drill/example", "/settings", "/settings/tag"])( + "locks %s to upright portrait", + (pathname) => { + expect(getMobileOrientationLock(pathname)).toBe( + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ); + }, + ); +}); diff --git a/apps/mobile/src/navigation/mobile-orientation.ts b/apps/mobile/src/navigation/mobile-orientation.ts new file mode 100644 index 00000000..33fd188b --- /dev/null +++ b/apps/mobile/src/navigation/mobile-orientation.ts @@ -0,0 +1,11 @@ +import * as ScreenOrientation from "expo-screen-orientation"; + +export function getMobileOrientationLock( + pathname: string, +): ScreenOrientation.OrientationLock { + const fieldRoute = pathname === "/field" || pathname.startsWith("/field/"); + + return fieldRoute + ? ScreenOrientation.OrientationLock.DEFAULT + : ScreenOrientation.OrientationLock.PORTRAIT_UP; +} diff --git a/apps/mobile/src/navigation/use-mobile-orientation-lock.ts b/apps/mobile/src/navigation/use-mobile-orientation-lock.ts new file mode 100644 index 00000000..67630fc8 --- /dev/null +++ b/apps/mobile/src/navigation/use-mobile-orientation-lock.ts @@ -0,0 +1,14 @@ +import React from "react"; +import { usePathname } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; + +import { getMobileOrientationLock } from "./mobile-orientation"; + +/** Keeps every route portrait-only except Field, which may rotate freely. */ +export function useMobileOrientationLock(): void { + const pathname = usePathname(); + + React.useEffect(() => { + void ScreenOrientation.lockAsync(getMobileOrientationLock(pathname)); + }, [pathname]); +} From e3418f5e28f4b831f797fb1b9bac9256eff0402b Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Tue, 4 Aug 2026 01:14:56 -0500 Subject: [PATCH 052/101] fix(mobile): keep settings tab after drill toggle --- apps/mobile/src/navigation/tab-bar-visibility-context.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx index 75df8af8..04869884 100644 --- a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx +++ b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx @@ -57,7 +57,7 @@ export function TabBarVisibilityProvider({ if (configuredDrillFeatures.current === enabled) return; configuredDrillFeatures.current = enabled; - router.replace("/(tabs)/field"); + router.replace("/(tabs)/settings"); dispatch({ type: "drill-features-reconfigured", enabled }); }, [router], From 16152cbd5ac86e5a5abb271badbc74bf38147e7a Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Tue, 4 Aug 2026 20:38:02 -0500 Subject: [PATCH 053/101] feat(drill-converter): Expand drill editing controls Add rule-based entity typing, names, prop sizing, color presets, and editable entity/set previews. Keep prop-only fields schema-safe and prevent the initial web layout from collapsing before hydration. --- apps/drill-converter/package.json | 2 + .../components/entity-settings-section.tsx | 200 ++++++--- .../src/components/preview-section.tsx | 406 +++++++++++++++--- apps/drill-converter/src/converter-screen.tsx | 49 ++- .../src/converter/__tests__/settings.test.ts | 91 +++- .../drill-converter/src/converter/settings.ts | 93 +++- .../src/converter/use-converter-controller.ts | 84 +++- apps/drill-converter/src/ui/form-controls.tsx | 6 +- package-lock.json | 2 + .../drill-schema/drill-document.schema.json | 111 ++++- .../drill-schema/src/__tests__/schema.test.ts | 149 ++++++- packages/drill-schema/src/entities.ts | 102 ++++- packages/drill-schema/src/schema.ts | 70 ++- packages/drill-schema/src/types.ts | 18 + 14 files changed, 1211 insertions(+), 172 deletions(-) diff --git a/apps/drill-converter/package.json b/apps/drill-converter/package.json index 09d060a4..c2f6bc2c 100644 --- a/apps/drill-converter/package.json +++ b/apps/drill-converter/package.json @@ -15,10 +15,12 @@ "dependencies": { "@eight2five/drill-importers": "0.0.0", "@eight2five/drill-schema": "0.0.0", + "@eight2five/ui": "0.0.0", "@expo/metro-runtime": "~57.0.8", "expo": "~57.0.9", "expo-document-picker": "~57.0.1", "expo-router": "~57.0.9", + "lucide-react-native": "^1.22.0", "react": "19.2.3", "react-dom": "^19.2.3", "react-native": "0.86.2", diff --git a/apps/drill-converter/src/components/entity-settings-section.tsx b/apps/drill-converter/src/components/entity-settings-section.tsx index cb34106e..33cf083c 100644 --- a/apps/drill-converter/src/components/entity-settings-section.tsx +++ b/apps/drill-converter/src/components/entity-settings-section.tsx @@ -1,5 +1,9 @@ import React from "react"; import { Pressable, Text, View } from "react-native"; +import { + convertPropSizeValue, + type PropSizeUnit, +} from "@eight2five/drill-schema"; import { ChoiceChips, @@ -22,18 +26,31 @@ const TARGET_OPTIONS = Object.freeze([ { value: "id", label: "ID" }, ] as const); +const ENTITY_TYPE_OPTIONS = Object.freeze([ + { value: "", label: "Default (Performer)" }, + { value: "performer", label: "Performer" }, + { value: "prop", label: "Prop" }, +] as const); + const LABEL_OPTIONS = Object.freeze([ - { value: "inherit", label: "Default" }, + { value: "inherit", label: "Default (Show)" }, { value: "visible", label: "Show" }, { value: "hidden", label: "Hide" }, ] as const); +const PROP_SIZE_UNIT_OPTIONS = Object.freeze([ + { value: "8-to-5-steps", label: "8:5 steps" }, + { value: "feet", label: "Feet" }, + { value: "inches", label: "Inches" }, + { value: "meters", label: "Meters" }, +] as const satisfies readonly { value: PropSizeUnit; label: string }[]); + export function EntitySettingsSection({ settings, availableSymbols, errors, + focusRequestKey = 0, onUpdate, - onTogglePropSymbol, onAddRule, onUpdateRule, onRemoveRule, @@ -41,8 +58,8 @@ export function EntitySettingsSection({ readonly settings: ConverterSettings; readonly availableSymbols: readonly string[]; readonly errors: readonly string[]; + readonly focusRequestKey?: number; readonly onUpdate: (patch: Partial) => void; - readonly onTogglePropSymbol: (symbol: string) => void; readonly onAddRule: (target?: EntityRuleDraft["target"]) => void; readonly onUpdateRule: ( id: string, @@ -53,50 +70,15 @@ export function EntitySettingsSection({ const ruleErrors = errors.filter( (error) => error.startsWith("Rule ") || error.startsWith("Duplicate "), ); + return ( 0} > - - Entity type by symbol - - {availableSymbols.length === 0 ? ( - - Select and parse a PDF first. Every extracted symbol defaults to a - performer; mark prop symbols here when needed. - - ) : ( - - {availableSymbols.map((symbol) => ( - onTogglePropSymbol(symbol)} - /> - ))} - - )} - - - Rules and overrides @@ -106,7 +88,8 @@ export function EntitySettingsSection({ style={{ color: colors.textMuted, fontSize: 12, lineHeight: 17 }} > Precedence is symbol → label → ID → explicit entity values. Leave a - field blank to inherit the broader rule or schema default. + field on its Default value to inherit the broader rule or schema + default. @@ -190,6 +173,9 @@ function RuleEditor({ const colorMatchesPreset = COLOR_PRESET_OPTIONS.some( (option) => option.value.toLowerCase() === rule.color.toLowerCase(), ); + const isProp = rule.entityType === "prop"; + const defaultIcon = isProp ? "Square" : "Dot"; + return ( + + + onUpdate( + entityType === "prop" + ? { + entityType, + section: "", + instrument: "", + sizeLength: rule.sizeLength.trim() ? rule.sizeLength : "1", + sizeWidth: rule.sizeWidth.trim() ? rule.sizeWidth : "1", + } + : { entityType }, + ) + } + /> + + onUpdate({ name })} + placeholder="Optional" + /> + + {isProp ? ( + + + + Prop size + + + Default is 1 × 1 8:5 steps. One 8:5 step is 22.5 in (1.875 ft / + 0.5715 m). + + + + + onUpdate({ + sizeUnit, + sizeLength: convertSizeDraftValue( + rule.sizeLength, + rule.sizeUnit, + sizeUnit, + ), + sizeWidth: convertSizeDraftValue( + rule.sizeWidth, + rule.sizeUnit, + sizeUnit, + ), + }) + } + /> + + + + onUpdate({ sizeLength })} + inputMode="decimal" + placeholder="1" + /> + + + onUpdate({ sizeWidth })} + inputMode="decimal" + placeholder="1" + /> + + + + ) : null} + onUpdate({ section })} - placeholder="Optional" + placeholder={isProp ? "Unavailable for props" : "Optional"} + editable={!isProp} + helper={isProp ? "Props cannot define a section." : undefined} /> @@ -277,7 +368,9 @@ function RuleEditor({ label="Instrument" value={rule.instrument} onChangeText={(instrument) => onUpdate({ instrument })} - placeholder="Optional" + placeholder={isProp ? "Unavailable for props" : "Optional"} + editable={!isProp} + helper={isProp ? "Props cannot define an instrument." : undefined} /> @@ -286,7 +379,7 @@ function RuleEditor({ label="Field icon" value={rule.icon} options={[ - { value: "", label: "Default" }, + { value: "", label: `Default (${defaultIcon})` }, ...ENTITY_ICON_OPTIONS.map((icon) => ({ value: icon, label: titleCase(icon), @@ -303,7 +396,7 @@ function RuleEditor({ style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }} > onUpdate({ color: "" })} @@ -324,8 +417,8 @@ function RuleEditor({ onChangeText={(color) => onUpdate({ color })} autoCapitalize="none" autoCorrect={false} - placeholder="#3c6ec8" - helper="Leave blank for the selected preset/default. Any six-digit hex color is valid." + placeholder="#3C6EC8" + helper="Leave blank for Default (Grey) or the selected preset. Any six-digit hex color is valid." /> @@ -396,6 +489,17 @@ function ColorChip({ ); } +function convertSizeDraftValue( + value: string, + fromUnit: PropSizeUnit, + toUnit: PropSizeUnit, +): string { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return value; + const converted = convertPropSizeValue(parsed, fromUnit, toUnit); + return String(Number(converted.toFixed(6))); +} + function titleCase(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } diff --git a/apps/drill-converter/src/components/preview-section.tsx b/apps/drill-converter/src/components/preview-section.tsx index 5b3cc751..0474b8b6 100644 --- a/apps/drill-converter/src/components/preview-section.tsx +++ b/apps/drill-converter/src/components/preview-section.tsx @@ -1,9 +1,22 @@ import React from "react"; -import { Pressable, Text, View } from "react-native"; +import { Modal, Pressable, ScrollView, Text, View } from "react-native"; import type { CoordinateSheetImportResult } from "@eight2five/drill-importers"; -import { formatSetName, type DrillDocument } from "@eight2five/drill-schema"; +import { + drillSetSchema, + formatSetName, + type DrillDocument, + type DrillSet, +} from "@eight2five/drill-schema"; +import { Divider } from "@eight2five/ui/components/divider"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pencil } from "lucide-react-native"; -import { PrimaryButton, SectionCard } from "../ui/form-controls"; +import { + FormField, + PrimaryButton, + SecondaryButton, + SectionCard, +} from "../ui/form-controls"; import { colors, radius, spacing } from "../ui/theme"; export function PreviewSection({ @@ -12,6 +25,8 @@ export function PreviewSection({ settingsErrors, summary, canDownload, + onEditEntityLabel, + onUpdateSet, onDownload, }: { readonly importResult?: CoordinateSheetImportResult; @@ -25,8 +40,11 @@ export function PreviewSection({ readonly positions: number; }; readonly canDownload: boolean; + readonly onEditEntityLabel: (label: string) => void; + readonly onUpdateSet: (set: DrillSet) => string | undefined; readonly onDownload: () => void; }) { + const [editingSet, setEditingSet] = React.useState(); const diagnostics = importResult?.diagnostics ?? []; const errors = [ ...settingsErrors, @@ -80,28 +98,38 @@ export function PreviewSection({ gap: spacing.lg, }} > - - `${entity.label} · ${entity.type} · symbol ${entity.symbol}`, - )} - /> - { + + {outputDocument.entities.map((entity, index) => ( + + {index > 0 ? : null} + onEditEntityLabel(entity.label)} + /> + + ))} + + + + {outputDocument.sets.map((set, index) => { const measures = set.measureRange ? set.measureRange.start === set.measureRange.end ? `m. ${set.measureRange.start}` : `m. ${set.measureRange.start}–${set.measureRange.end}` : "measures —"; - return `${formatSetName(set)} · ${ - set.countsFromPrevious - } ct · ${measures}`; + return ( + + {index > 0 ? : null} + setEditingSet(set)} + /> + + ); })} - /> + + + {editingSet ? ( + setEditingSet(undefined)} + onSave={onUpdateSet} + /> + ) : null} ); } @@ -170,19 +207,13 @@ function Metric({ ); } -const PREVIEW_PAGE_SIZE = 10; - -function PreviewList({ +function ScrollablePreviewBox({ title, - values, + children, }: { readonly title: string; - readonly values: readonly string[]; + readonly children: React.ReactNode; }) { - const [visibleCount, setVisibleCount] = React.useState(PREVIEW_PAGE_SIZE); - const visibleValues = values.slice(0, visibleCount); - const remainder = Math.max(0, values.length - visibleValues.length); - return ( @@ -190,50 +221,303 @@ function PreviewList({ - {visibleValues.map((value) => ( - - {value} - - ))} - {remainder > 0 ? ( - - setVisibleCount((count) => - Math.min(count + PREVIEW_PAGE_SIZE, values.length), - ) - } - style={({ pressed }) => ({ - alignSelf: "flex-start", - borderRadius: radius.sm, - paddingHorizontal: spacing.xs, - paddingVertical: 2, - opacity: pressed ? 0.65 : 1, - })} - > - - + {remainder} more - - - ) : null} + + {children} + ); } +function PreviewDivider() { + return ( + + ); +} + +function PreviewRow({ + text, + editLabel, + onEdit, +}: { + readonly text: string; + readonly editLabel: string; + readonly onEdit: () => void; +}) { + return ( + + + {text} + + ({ + width: 30, + height: 30, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + backgroundColor: pressed ? colors.accentSoft : "transparent", + })} + > + + + + ); +} + +function SetEditModal({ + set, + onClose, + onSave, +}: { + readonly set: DrillSet; + readonly onClose: () => void; + readonly onSave: (set: DrillSet) => string | undefined; +}) { + const [number, setNumber] = React.useState(String(set.number)); + const [suffix, setSuffix] = React.useState(set.suffix ?? ""); + const [counts, setCounts] = React.useState(String(set.countsFromPrevious)); + const [measureStart, setMeasureStart] = React.useState( + set.measureRange ? String(set.measureRange.start) : "", + ); + const [measureEnd, setMeasureEnd] = React.useState( + set.measureRange ? String(set.measureRange.end) : "", + ); + const [error, setError] = React.useState(); + + const save = () => { + const parsedNumber = parseNonNegativeInteger(number); + if (parsedNumber === undefined) { + setError("Set number must be a non-negative whole number."); + return; + } + const parsedCounts = parseNonNegativeInteger(counts); + if (parsedCounts === undefined) { + setError("Counts from previous must be a non-negative whole number."); + return; + } + + const trimmedSuffix = suffix.trim(); + const hasMeasureStart = measureStart.trim().length > 0; + const hasMeasureEnd = measureEnd.trim().length > 0; + if (hasMeasureStart !== hasMeasureEnd) { + setError( + "Enter both measure start and measure end, or leave both blank.", + ); + return; + } + + let measureRange: DrillSet["measureRange"]; + if (hasMeasureStart && hasMeasureEnd) { + const start = parseNonNegativeInteger(measureStart); + const end = parseNonNegativeInteger(measureEnd); + if (start === undefined || end === undefined) { + setError("Measure numbers must be non-negative whole numbers."); + return; + } + measureRange = { start, end }; + } + + const candidate = { + id: set.id, + number: parsedNumber, + ...(trimmedSuffix ? { suffix: trimmedSuffix } : {}), + kind: trimmedSuffix ? ("subset" as const) : ("set" as const), + countsFromPrevious: parsedCounts, + ...(measureRange ? { measureRange } : {}), + }; + const parsed = drillSetSchema.safeParse(candidate); + if (!parsed.success) { + setError( + parsed.error.issues[0]?.message ?? "The set values are invalid.", + ); + return; + } + + const documentError = onSave(parsed.data); + if (documentError) { + setError(documentError); + return; + } + onClose(); + }; + + return ( + + + + + + Edit set {formatSetName(set)} + + + Set identity uses a numeric number plus an optional suffix. A + blank suffix is a primary set; a capital letter or decimal suffix + such as A or .5 makes it a subset. + + + + + + + + + + + + + + + + + + + + + + + + + + + + {error ? ( + + {error} + + ) : null} + + + + + + + + + + + ); +} + +function parseNonNegativeInteger(value: string): number | undefined { + const trimmed = value.trim(); + if (!/^(?:0|[1-9][0-9]*)$/.test(trimmed)) return undefined; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + function DiagnosticBox({ title, messages, diff --git a/apps/drill-converter/src/converter-screen.tsx b/apps/drill-converter/src/converter-screen.tsx index e89da18d..23cbb637 100644 --- a/apps/drill-converter/src/converter-screen.tsx +++ b/apps/drill-converter/src/converter-screen.tsx @@ -10,14 +10,29 @@ import { colors, spacing } from "./ui/theme"; export function ConverterScreen() { const controller = useConverterController(); + const { addLabelOverride } = controller; + const scrollViewRef = React.useRef(null); + const [rulesSectionY, setRulesSectionY] = React.useState(0); + const [rulesFocusRequestKey, setRulesFocusRequestKey] = React.useState(0); const { width } = useWindowDimensions(); - const contentWidth = Math.min( - 1040, - Math.max(0, width - (width < 720 ? 28 : 64)), + + const editEntityLabel = React.useCallback( + (label: string) => { + addLabelOverride(label); + setRulesFocusRequestKey((key) => key + 1); + requestAnimationFrame(() => { + scrollViewRef.current?.scrollTo({ + y: Math.max(0, rulesSectionY - spacing.md), + animated: true, + }); + }); + }, + [addLabelOverride, rulesSectionY], ); return ( - + - + setRulesSectionY(event.nativeEvent.layout.y)} + > + + diff --git a/apps/drill-converter/src/converter/__tests__/settings.test.ts b/apps/drill-converter/src/converter/__tests__/settings.test.ts index 0dd227b7..5bb626d4 100644 --- a/apps/drill-converter/src/converter/__tests__/settings.test.ts +++ b/apps/drill-converter/src/converter/__tests__/settings.test.ts @@ -76,9 +76,18 @@ describe("drill converter settings", () => { ...createEmptyRuleDraft("rule-2"), target: "label" as const, key: "B1", + name: "Lead Baritone", color: COLOR_PRESETS.green, labelVisibility: "hidden" as const, }; + const propRule = { + ...createEmptyRuleDraft("rule-3"), + key: "X", + entityType: "prop" as const, + sizeLength: "45", + sizeWidth: "22.5", + sizeUnit: "inches" as const, + }; const settings = { ...createDefaultConverterSettings(), title: "Part 4", @@ -86,8 +95,15 @@ describe("drill converter settings", () => { ensemble: "UHS", description: "Final movement", lucideIcon: "music-2", - propSymbols: ["X"], - rules: [symbolRule, labelRule], + rules: [symbolRule, labelRule, propRule], + setOverrides: [ + { + ...source.sets[1], + number: 3, + countsFromPrevious: 12, + measureRange: { start: 10, end: 12 }, + }, + ], includeSourceReferences: false, explicitStraightPaths: true, }; @@ -103,16 +119,87 @@ describe("drill converter settings", () => { lucideIcon: "music-2", }); expect(result.entities[1].type).toBe("prop"); + expect( + resolveDrillEntity(result.entities[1], result.entityRules), + ).toMatchObject({ + size: { length: 45, width: 22.5, unit: "inches" }, + }); + expect(result.sets[1]).toMatchObject({ + number: 3, + countsFromPrevious: 12, + measureRange: { start: 10, end: 12 }, + }); expect(result.provenance?.references).toBeUndefined(); expect(result.paths).toHaveLength(2); expect( resolveDrillEntity(result.entities[0], result.entityRules), ).toMatchObject({ + name: "Lead Baritone", instrument: "Baritone", appearance: { color: COLOR_PRESETS.green, labelVisible: false }, }); }); + test("defaults prop rules to 1 by 1 8-to-5 steps", () => { + const rule = { + ...createEmptyRuleDraft("prop-rule"), + key: "X", + entityType: "prop" as const, + }; + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + rules: [rule], + }; + const validation = validateConverterSettings(settings); + expect(validation.errors).toEqual([]); + expect(validation.entityRules?.bySymbol?.X?.size).toEqual({ + length: 1, + width: 1, + unit: "8-to-5-steps", + }); + }); + + test("rejects invalid prop sizes", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + rules: [ + { + ...createEmptyRuleDraft("prop-rule"), + key: "X", + entityType: "prop" as const, + sizeLength: "0", + }, + ], + }; + expect(validateConverterSettings(settings).errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("prop length must be greater than zero"), + ]), + ); + }); + + test("rejects prop rules that define performer-only fields", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + rules: [ + { + ...createEmptyRuleDraft("prop-rule"), + key: "X", + entityType: "prop" as const, + section: "Guard", + }, + ], + }; + expect(validateConverterSettings(settings).errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("props cannot define section or instrument"), + ]), + ); + }); + test("rejects malformed custom fields and duplicate rule targets", () => { const settings = { ...createDefaultConverterSettings(), diff --git a/apps/drill-converter/src/converter/settings.ts b/apps/drill-converter/src/converter/settings.ts index b9271532..90fd3eac 100644 --- a/apps/drill-converter/src/converter/settings.ts +++ b/apps/drill-converter/src/converter/settings.ts @@ -2,16 +2,21 @@ import { COLOR_PRESETS, FIELD_PRESET_IDS, countPrimarySets, + drillSetSchema, fieldDefinitionSchema, getFieldPreset, parseDrillDocument, + resolveEntityRuleValues, type DrillDocument, + type DrillEntityType, type DrillPath, + type DrillSet, type EntityIcon, type EntityRuleValues, type EntityRules, type FieldDefinition, type FieldPresetId, + type PropSizeUnit, } from "@eight2five/drill-schema"; export const FIELD_PRESET_OPTIONS = Object.freeze( @@ -40,9 +45,12 @@ export const COLOR_PRESET_OPTIONS = Object.freeze([ { label: "Orange", value: COLOR_PRESETS.orange }, { label: "Yellow", value: COLOR_PRESETS.yellow }, { label: "Green", value: COLOR_PRESETS.green }, + { label: "Light blue", value: COLOR_PRESETS.lightBlue }, { label: "Blue", value: COLOR_PRESETS.blue }, - { label: "Indigo", value: COLOR_PRESETS.indigo }, - { label: "Violet", value: COLOR_PRESETS.violet }, + { label: "Dark blue", value: COLOR_PRESETS.darkBlue }, + { label: "Purple", value: COLOR_PRESETS.purple }, + { label: "Pink", value: COLOR_PRESETS.pink }, + { label: "Black", value: COLOR_PRESETS.black }, ] as const); export type RuleTarget = "symbol" | "label" | "id"; @@ -52,8 +60,13 @@ export interface EntityRuleDraft { readonly id: string; readonly target: RuleTarget; readonly key: string; + readonly entityType: "" | DrillEntityType; + readonly name: string; readonly section: string; readonly instrument: string; + readonly sizeLength: string; + readonly sizeWidth: string; + readonly sizeUnit: PropSizeUnit; readonly icon: "" | EntityIcon; readonly color: string; readonly labelVisibility: LabelVisibility; @@ -67,8 +80,8 @@ export interface ConverterSettings { readonly lucideIcon: string; readonly fieldMode: FieldPresetId | "custom"; readonly customFieldJson: string; - readonly propSymbols: readonly string[]; readonly rules: readonly EntityRuleDraft[]; + readonly setOverrides: readonly DrillSet[]; readonly includeSourceReferences: boolean; readonly explicitStraightPaths: boolean; } @@ -88,8 +101,8 @@ export function createDefaultConverterSettings(): ConverterSettings { lucideIcon: "", fieldMode: "football-nfhs", customFieldJson: createDefaultCustomFieldJson(), - propSymbols: [], rules: [], + setOverrides: [], includeSourceReferences: true, explicitStraightPaths: false, }; @@ -114,8 +127,13 @@ export function createEmptyRuleDraft(id: string): EntityRuleDraft { id, target: "symbol", key: "", + entityType: "", + name: "", section: "", instrument: "", + sizeLength: "1", + sizeWidth: "1", + sizeUnit: "8-to-5-steps", icon: "", color: "", labelVisibility: "inherit", @@ -157,8 +175,21 @@ export function validateConverterSettings( } const entityRules = buildEntityRules(settings.rules, errors); - for (const symbol of settings.propSymbols) { - if (!symbol.trim()) errors.push("Prop symbols cannot be blank."); + const seenSetOverrideIds = new Set(); + for (const [index, set] of settings.setOverrides.entries()) { + if (seenSetOverrideIds.has(set.id)) { + errors.push(`Duplicate set override for set id ${set.id}.`); + continue; + } + seenSetOverrideIds.add(set.id); + const parsed = drillSetSchema.safeParse(set); + if (!parsed.success) { + errors.push( + `Set override ${index + 1} is invalid: ${ + parsed.error.issues[0]?.message ?? "unknown validation error" + }`, + ); + } } return { @@ -177,13 +208,22 @@ export function applyConverterSettings( throw new Error(validation.errors[0] ?? "Converter settings are invalid."); } - const propSymbols = new Set(settings.propSymbols); - const entities = source.entities.map((entity) => ({ - ...entity, - type: propSymbols.has(entity.symbol) ? ("prop" as const) : entity.type, - })); + const entities = source.entities.map((entity) => { + const ruleValues = resolveEntityRuleValues(entity, validation.entityRules); + const type = ruleValues.type ?? entity.type; + if (type === "prop") { + const { section: _section, instrument: _instrument, ...rest } = entity; + return { ...rest, type }; + } + const { size: _size, ...rest } = entity; + return { ...rest, type }; + }); + const setOverrides = new Map( + settings.setOverrides.map((set) => [set.id, set] as const), + ); + const sets = source.sets.map((set) => setOverrides.get(set.id) ?? set); const paths = settings.explicitStraightPaths - ? createStraightPaths(source) + ? createStraightPaths({ ...source, sets }) : source.paths; const provenance = source.provenance ? { @@ -209,6 +249,7 @@ export function applyConverterSettings( ? { entityRules: validation.entityRules } : { entityRules: undefined }), entities, + sets, ...(paths && paths.length > 0 ? { paths } : { paths: undefined }), ...(provenance ? { provenance } : { provenance: undefined }), }); @@ -285,6 +326,31 @@ function buildEntityRules( continue; } + if ( + draft.entityType === "prop" && + (draft.section.trim() || draft.instrument.trim()) + ) { + errors.push( + `Rule ${index + 1} props cannot define section or instrument.`, + ); + continue; + } + + let size: EntityRuleValues["size"]; + if (draft.entityType === "prop") { + const length = Number(draft.sizeLength); + const width = Number(draft.sizeWidth); + if (!Number.isFinite(length) || length <= 0) { + errors.push(`Rule ${index + 1} prop length must be greater than zero.`); + continue; + } + if (!Number.isFinite(width) || width <= 0) { + errors.push(`Rule ${index + 1} prop width must be greater than zero.`); + continue; + } + size = { length, width, unit: draft.sizeUnit }; + } + const appearance = { ...(draft.icon ? { icon: draft.icon } : {}), ...(color ? { color } : {}), @@ -293,8 +359,11 @@ function buildEntityRules( : { labelVisible: draft.labelVisibility === "visible" }), }; const values: EntityRuleValues = { + ...(draft.entityType ? { type: draft.entityType } : {}), + ...optionalText("name", draft.name), ...optionalText("section", draft.section), ...optionalText("instrument", draft.instrument), + ...(size ? { size } : {}), ...(Object.keys(appearance).length > 0 ? { appearance } : {}), }; if (Object.keys(values).length === 0) continue; diff --git a/apps/drill-converter/src/converter/use-converter-controller.ts b/apps/drill-converter/src/converter/use-converter-controller.ts index 0c5aedf6..fe74dfdb 100644 --- a/apps/drill-converter/src/converter/use-converter-controller.ts +++ b/apps/drill-converter/src/converter/use-converter-controller.ts @@ -117,8 +117,8 @@ export function useConverterController() { setSettings((current) => ({ ...current, title: inferTitleFromFileName(nextAsset.name), - propSymbols: [], rules: [], + setOverrides: [], })); try { @@ -149,8 +149,8 @@ export function useConverterController() { setSettings((current) => ({ ...current, title: "", - propSymbols: [], rules: [], + setOverrides: [], })); }, []); @@ -173,13 +173,47 @@ export function useConverterController() { [], ); + const addLabelOverride = React.useCallback((label: string) => { + const key = label.trim(); + if (!key) return; + setSettings((current) => { + const existing = current.rules.find( + (rule) => rule.target === "label" && rule.key.trim() === key, + ); + if (existing) { + return { + ...current, + rules: [ + existing, + ...current.rules.filter((rule) => rule.id !== existing.id), + ], + }; + } + return { + ...current, + rules: [ + { + ...createEmptyRuleDraft(`rule-${nextRuleId++}`), + target: "label", + key, + }, + ...current.rules, + ], + }; + }); + }, []); + const updateRule = React.useCallback( (id: string, patch: Partial>) => { setSettings((current) => ({ ...current, - rules: current.rules.map((rule) => - rule.id === id ? { ...rule, ...patch } : rule, - ), + rules: current.rules.map((rule) => { + if (rule.id !== id) return rule; + const nextRule = { ...rule, ...patch }; + return nextRule.entityType === "prop" + ? { ...nextRule, section: "", instrument: "" } + : nextRule; + }), })); }, [], @@ -192,14 +226,35 @@ export function useConverterController() { })); }, []); - const togglePropSymbol = React.useCallback((symbol: string) => { - setSettings((current) => ({ - ...current, - propSymbols: current.propSymbols.includes(symbol) - ? current.propSymbols.filter((candidate) => candidate !== symbol) - : [...current.propSymbols, symbol], - })); - }, []); + const updateSet = React.useCallback( + (setOverride: DrillDocument["sets"][number]): string | undefined => { + if (!importResult?.document) return "Parse a PDF before editing sets."; + if ( + !importResult.document.sets.some((set) => set.id === setOverride.id) + ) { + return `Unknown set id ${setOverride.id}.`; + } + + const nextSettings: ConverterSettings = { + ...settings, + setOverrides: [ + ...settings.setOverrides.filter((set) => set.id !== setOverride.id), + setOverride, + ], + }; + const validation = validateConverterSettings(nextSettings); + try { + applyConverterSettings(importResult.document, nextSettings, validation); + } catch (cause) { + return cause instanceof Error + ? cause.message + : "The set edit is invalid."; + } + setSettings(nextSettings); + return undefined; + }, + [importResult, settings], + ); const download = React.useCallback(() => { if (!outputDocument) return; @@ -226,9 +281,10 @@ export function useConverterController() { clearPdf, updateSettings, addRule, + addLabelOverride, updateRule, removeRule, - togglePropSymbol, + updateSet, download, }; } diff --git a/apps/drill-converter/src/ui/form-controls.tsx b/apps/drill-converter/src/ui/form-controls.tsx index 3ba5af66..c5a85ef1 100644 --- a/apps/drill-converter/src/ui/form-controls.tsx +++ b/apps/drill-converter/src/ui/form-controls.tsx @@ -49,9 +49,11 @@ export function FormField({ borderRadius: radius.sm, paddingHorizontal: 12, paddingVertical: multiline ? 10 : 8, - backgroundColor: colors.surface, - color: colors.text, + backgroundColor: + props.editable === false ? colors.surfaceMuted : colors.surface, + color: props.editable === false ? colors.textMuted : colors.text, fontSize: 15, + opacity: props.editable === false ? 0.7 : 1, ...(multiline ? { textAlignVertical: "top" as const } : {}), }} accessibilityLabel={label} diff --git a/package-lock.json b/package-lock.json index 5f1b4746..76de6dfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,10 +27,12 @@ "dependencies": { "@eight2five/drill-importers": "0.0.0", "@eight2five/drill-schema": "0.0.0", + "@eight2five/ui": "0.0.0", "@expo/metro-runtime": "~57.0.8", "expo": "~57.0.9", "expo-document-picker": "~57.0.1", "expo-router": "~57.0.9", + "lucide-react-native": "^1.22.0", "react": "19.2.3", "react-dom": "^19.2.3", "react-native": "0.86.2", diff --git a/packages/drill-schema/drill-document.schema.json b/packages/drill-schema/drill-document.schema.json index d93c7c28..b5fa91a2 100644 --- a/packages/drill-schema/drill-document.schema.json +++ b/packages/drill-schema/drill-document.schema.json @@ -184,13 +184,44 @@ }, "color": { "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" + "pattern": "^#[0-9A-Fa-f]{6}$", + "examples": [ + "#808080", + "#E53935", + "#FB8C00", + "#FDD835", + "#43A047", + "#64B5F6", + "#3C6EC8", + "#1E3A8A", + "#8E44AD", + "#EC4899", + "#000000" + ] }, "labelVisible": { "type": "boolean" } } }, + "propSize": { + "type": "object", + "additionalProperties": false, + "required": ["length", "width", "unit"], + "properties": { + "length": { + "type": "number", + "exclusiveMinimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "unit": { + "enum": ["8-to-5-steps", "feet", "inches", "meters"] + } + } + }, "entity": { "type": "object", "additionalProperties": false, @@ -223,15 +254,56 @@ "type": "string", "minLength": 1 }, + "size": { + "$ref": "#/$defs/propSize", + "default": { + "length": 1, + "width": 1, + "unit": "8-to-5-steps" + }, + "description": "Prop footprint. If omitted for a prop, the semantic default is 1 by 1 8-to-5 steps; one 8-to-5 step equals 22.5 inches." + }, "appearance": { "$ref": "#/$defs/appearance" } - } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "prop" + } + }, + "required": ["type"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["section"] }, + { "required": ["instrument"] } + ] + } + }, + "else": { + "not": { + "required": ["size"] + } + } + } + ] }, "entityRuleValues": { "type": "object", "additionalProperties": false, "properties": { + "type": { + "enum": ["performer", "prop"] + }, + "name": { + "type": "string", + "minLength": 1 + }, "section": { "type": "string", "minLength": 1 @@ -240,10 +312,43 @@ "type": "string", "minLength": 1 }, + "size": { + "$ref": "#/$defs/propSize", + "default": { + "length": 1, + "width": 1, + "unit": "8-to-5-steps" + } + }, "appearance": { "$ref": "#/$defs/appearance" } - } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "prop" + } + }, + "required": ["type"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["section"] }, + { "required": ["instrument"] } + ] + } + }, + "else": { + "not": { + "required": ["size"] + } + } + } + ] }, "entityRules": { "type": "object", diff --git a/packages/drill-schema/src/__tests__/schema.test.ts b/packages/drill-schema/src/__tests__/schema.test.ts index dadca60c..08c4e327 100644 --- a/packages/drill-schema/src/__tests__/schema.test.ts +++ b/packages/drill-schema/src/__tests__/schema.test.ts @@ -1,8 +1,10 @@ import { COLOR_PRESETS, + DEFAULT_PROP_SIZE, FIELD_PRESETS, FIELD_PRESET_IDS, countPrimarySets, + convertPropSizeValue, drillGridToPhysicalPoint, formatSetName, getFieldPreset, @@ -11,6 +13,8 @@ import { parseDrillDocument, physicalPointToDrillGrid, resolveDrillEntity, + resolveEntityRuleValues, + resolvePropSize, serializeDrillDocument, type DrillDocument, } from ".."; @@ -132,14 +136,155 @@ describe("drill schema", () => { ).not.toThrow(); }); - it("resolves appearance rules from broad to specific", () => { - const entity = resolveDrillEntity(fixture.entities[0], fixture.entityRules); + it("resolves entity rules from broad to specific", () => { + const rules = { + ...fixture.entityRules, + byLabel: { + B1: { + name: "Lead Baritone", + appearance: { color: COLOR_PRESETS.green }, + }, + }, + }; + const entity = resolveDrillEntity(fixture.entities[0], rules); + expect(entity.name).toBe("Lead Baritone"); expect(entity.instrument).toBe("Baritone"); expect(entity.appearance).toEqual({ icon: "dot", color: COLOR_PRESETS.green, labelVisible: true, }); + expect(resolveEntityRuleValues(fixture.entities[0], rules)).toMatchObject({ + name: "Lead Baritone", + instrument: "Baritone", + }); + }); + + it("allows names on both performers and props but rejects performer-only prop fields", () => { + expect(() => + parseDrillDocument({ + ...fixture, + entities: [ + { + id: 1595433022185, + type: "prop", + symbol: "P", + label: "P1", + name: "Podium", + }, + ], + }), + ).not.toThrow(); + + expect(() => + parseDrillDocument({ + ...fixture, + entities: [ + { + id: 1595433022185, + type: "prop", + symbol: "P", + label: "P1", + name: "Podium", + section: "Guard", + }, + ], + }), + ).toThrow(/Props cannot define a section/); + + expect(() => + parseDrillDocument({ + ...fixture, + entityRules: { + bySymbol: { + P: { type: "prop", instrument: "Flag" }, + }, + }, + }), + ).toThrow(/Prop rules cannot define an instrument/); + }); + + it("defaults prop size to 1 by 1 8-to-5 steps and supports unit conversion", () => { + const prop = { + ...fixture.entities[0], + type: "prop" as const, + symbol: "P", + label: "P1", + }; + expect(resolvePropSize(prop)).toEqual(DEFAULT_PROP_SIZE); + expect(resolveDrillEntity(prop).size).toEqual(DEFAULT_PROP_SIZE); + expect(convertPropSizeValue(1, "8-to-5-steps", "inches")).toBeCloseTo( + 22.5, + 8, + ); + expect(convertPropSizeValue(1, "8-to-5-steps", "feet")).toBeCloseTo( + 1.875, + 8, + ); + expect(convertPropSizeValue(1, "8-to-5-steps", "meters")).toBeCloseTo( + 0.5715, + 8, + ); + }); + + it.each(["8-to-5-steps", "feet", "inches", "meters"] as const)( + "accepts prop size in %s", + (unit) => { + expect(() => + parseDrillDocument({ + ...fixture, + entities: [ + { + id: 1595433022185, + type: "prop", + symbol: "P", + label: "P1", + size: { length: 2.5, width: 1.25, unit }, + }, + ], + }), + ).not.toThrow(); + }, + ); + + it("rejects size on performers and non-prop rules", () => { + expect(() => + parseDrillDocument({ + ...fixture, + entities: [ + { + ...fixture.entities[0], + size: { length: 1, width: 1, unit: "8-to-5-steps" }, + }, + ], + }), + ).toThrow(/Only props can define a size/); + + expect(() => + parseDrillDocument({ + ...fixture, + entityRules: { + bySymbol: { + B: { + type: "performer", + size: { length: 1, width: 1, unit: "feet" }, + }, + }, + }, + }), + ).toThrow(/Only prop rules can define a size/); + }); + + it("exposes the canonical color presets without indigo or violet", () => { + expect(COLOR_PRESETS).toMatchObject({ + lightBlue: "#64B5F6", + darkBlue: "#1E3A8A", + purple: "#8E44AD", + pink: "#EC4899", + black: "#000000", + }); + expect("indigo" in COLOR_PRESETS).toBe(false); + expect("violet" in COLOR_PRESETS).toBe(false); }); it("exposes all field preset ids from one canonical registry", () => { diff --git a/packages/drill-schema/src/entities.ts b/packages/drill-schema/src/entities.ts index 6741eb5c..71cfa4e8 100644 --- a/packages/drill-schema/src/entities.ts +++ b/packages/drill-schema/src/entities.ts @@ -3,21 +3,32 @@ import type { EntityAppearance, EntityRuleValues, EntityRules, + PropSize, + PropSizeUnit, ResolvedDrillEntity, ResolvedEntityAppearance, } from "./types"; export const DEFAULT_ENTITY_COLOR = "#808080" as const; +export const EIGHT_TO_FIVE_STEP_INCHES = 22.5 as const; +export const DEFAULT_PROP_SIZE = Object.freeze({ + length: 1, + width: 1, + unit: "8-to-5-steps", +} as const satisfies PropSize); export const COLOR_PRESETS = Object.freeze({ + grey: DEFAULT_ENTITY_COLOR, red: "#E53935", orange: "#FB8C00", yellow: "#FDD835", green: "#43A047", - blue: "#3c6ec8", - indigo: "#4F51B5", - violet: "#8E44AD", - grey: DEFAULT_ENTITY_COLOR, + lightBlue: "#64B5F6", + blue: "#3C6EC8", + darkBlue: "#1E3A8A", + purple: "#8E44AD", + pink: "#EC4899", + black: "#000000", } as const); export function resolveDrillEntity( @@ -27,18 +38,26 @@ export function resolveDrillEntity( const symbolRule = rules?.bySymbol?.[entity.symbol]; const labelRule = rules?.byLabel?.[entity.label]; const idRule = rules?.byId?.[String(entity.id)]; + const mergedRule = resolveEntityRuleValues(entity, rules); + const type = entity.type ?? mergedRule.type ?? "performer"; + const name = entity.name ?? mergedRule.name; + const section = type === "prop" ? undefined : entity.section ?? mergedRule.section; + const instrument = + type === "prop" ? undefined : entity.instrument ?? mergedRule.instrument; + const size = + type === "prop" + ? entity.size ?? mergedRule.size ?? DEFAULT_PROP_SIZE + : undefined; - const mergedRule = mergeRuleValues(symbolRule, labelRule, idRule); return { ...entity, - ...(mergedRule.section === undefined ? {} : { section: mergedRule.section }), - ...(mergedRule.instrument === undefined - ? {} - : { instrument: mergedRule.instrument }), - ...(entity.section === undefined ? {} : { section: entity.section }), - ...(entity.instrument === undefined ? {} : { instrument: entity.instrument }), + type, + ...(name === undefined ? {} : { name }), + ...(section === undefined ? {} : { section }), + ...(instrument === undefined ? {} : { instrument }), + ...(size === undefined ? {} : { size }), appearance: resolveAppearance( - entity.type, + type, symbolRule?.appearance, labelRule?.appearance, idRule?.appearance, @@ -47,6 +66,17 @@ export function resolveDrillEntity( }; } +export function resolveEntityRuleValues( + entity: Pick, + rules?: EntityRules, +): EntityRuleValues { + return mergeRuleValues( + rules?.bySymbol?.[entity.symbol], + rules?.byLabel?.[entity.label], + rules?.byId?.[String(entity.id)], + ); +} + export function resolveEntityAppearance( entity: DrillEntity, rules?: EntityRules, @@ -54,6 +84,37 @@ export function resolveEntityAppearance( return resolveDrillEntity(entity, rules).appearance; } +export function resolvePropSize( + entity: DrillEntity, + rules?: EntityRules, +): PropSize | undefined { + const resolved = resolveDrillEntity(entity, rules); + return resolved.type === "prop" ? resolved.size ?? DEFAULT_PROP_SIZE : undefined; +} + +export function convertPropSizeValue( + value: number, + fromUnit: PropSizeUnit, + toUnit: PropSizeUnit, +): number { + if (fromUnit === toUnit) return value; + const meters = value * propSizeUnitMeters(fromUnit); + return meters / propSizeUnitMeters(toUnit); +} + +function propSizeUnitMeters(unit: PropSizeUnit): number { + switch (unit) { + case "8-to-5-steps": + return EIGHT_TO_FIVE_STEP_INCHES * 0.0254; + case "feet": + return 0.3048; + case "inches": + return 0.0254; + case "meters": + return 1; + } +} + function resolveAppearance( type: DrillEntity["type"], ...appearances: readonly (EntityAppearance | undefined)[] @@ -77,15 +138,32 @@ function resolveAppearance( function mergeRuleValues( ...values: readonly (EntityRuleValues | undefined)[] ): EntityRuleValues { + let type: EntityRuleValues["type"]; + let name: string | undefined; let section: string | undefined; let instrument: string | undefined; + let size: PropSize | undefined; + let appearance: EntityAppearance | undefined; for (const value of values) { if (!value) continue; + type = value.type ?? type; + name = value.name ?? name; section = value.section ?? section; instrument = value.instrument ?? instrument; + size = value.size ?? size; + if (value.appearance) { + appearance = { + ...(appearance ?? {}), + ...value.appearance, + }; + } } return { + ...(type === undefined ? {} : { type }), + ...(name === undefined ? {} : { name }), ...(section === undefined ? {} : { section }), ...(instrument === undefined ? {} : { instrument }), + ...(size === undefined ? {} : { size }), + ...(appearance === undefined ? {} : { appearance }), }; } diff --git a/packages/drill-schema/src/schema.ts b/packages/drill-schema/src/schema.ts index e815c47c..1a65f9e2 100644 --- a/packages/drill-schema/src/schema.ts +++ b/packages/drill-schema/src/schema.ts @@ -5,6 +5,7 @@ import { DRILL_SCHEMA_URL, DRILL_SCHEMA_VERSION, FIELD_PRESET_IDS, + PROP_SIZE_UNITS, type DrillDocument, } from "./types"; @@ -14,6 +15,7 @@ const safeNonNegativeInteger = z .min(0) .max(Number.MAX_SAFE_INTEGER); const finiteNumber = z.number().finite(); +const positiveFiniteNumber = finiteNumber.gt(0); const nonEmptyText = z.string().trim().min(1); const hexColor = z.string().regex(/^#[0-9A-Fa-f]{6}$/); const symbol = z.string().trim().min(1).max(16); @@ -80,6 +82,14 @@ export const entityAppearanceSchema = z }) .strict(); +export const propSizeSchema = z + .object({ + length: positiveFiniteNumber, + width: positiveFiniteNumber, + unit: z.enum(PROP_SIZE_UNITS), + }) + .strict(); + export const drillEntitySchema = z .object({ id: safeNonNegativeInteger, @@ -89,17 +99,73 @@ export const drillEntitySchema = z name: nonEmptyText.optional(), section: nonEmptyText.optional(), instrument: nonEmptyText.optional(), + size: propSizeSchema.optional(), appearance: entityAppearanceSchema.optional(), }) - .strict(); + .strict() + .superRefine((entity, context) => { + if (entity.type !== "prop") { + if (entity.size !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["size"], + message: "Only props can define a size.", + }); + } + return; + } + if (entity.section !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["section"], + message: "Props cannot define a section.", + }); + } + if (entity.instrument !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["instrument"], + message: "Props cannot define an instrument.", + }); + } + }); export const entityRuleValuesSchema = z .object({ + type: z.enum(["performer", "prop"]).optional(), + name: nonEmptyText.optional(), section: nonEmptyText.optional(), instrument: nonEmptyText.optional(), + size: propSizeSchema.optional(), appearance: entityAppearanceSchema.optional(), }) - .strict(); + .strict() + .superRefine((rule, context) => { + if (rule.type !== "prop") { + if (rule.size !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["size"], + message: "Only prop rules can define a size.", + }); + } + return; + } + if (rule.section !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["section"], + message: "Prop rules cannot define a section.", + }); + } + if (rule.instrument !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["instrument"], + message: "Prop rules cannot define an instrument.", + }); + } + }); const ruleMapSchema = z.record(entityRuleValuesSchema); diff --git a/packages/drill-schema/src/types.ts b/packages/drill-schema/src/types.ts index 0e84c056..55ccf362 100644 --- a/packages/drill-schema/src/types.ts +++ b/packages/drill-schema/src/types.ts @@ -3,6 +3,20 @@ export const DRILL_SCHEMA_VERSION = "1.0.0" as const; export type SetKind = "set" | "subset"; export type DrillEntityType = "performer" | "prop"; +export const PROP_SIZE_UNITS = Object.freeze([ + "8-to-5-steps", + "feet", + "inches", + "meters", +] as const); +export type PropSizeUnit = (typeof PROP_SIZE_UNITS)[number]; + +export interface PropSize { + readonly length: number; + readonly width: number; + readonly unit: PropSizeUnit; +} + export type EntityIcon = | "dot" | "square" @@ -57,12 +71,16 @@ export interface DrillEntity { readonly name?: string; readonly section?: string; readonly instrument?: string; + readonly size?: PropSize; readonly appearance?: EntityAppearance; } export interface EntityRuleValues { + readonly type?: DrillEntityType; + readonly name?: string; readonly section?: string; readonly instrument?: string; + readonly size?: PropSize; readonly appearance?: EntityAppearance; } From 8d76498fb2d4f303205b2edb98f926f06dd8175b Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 02:22:24 -0500 Subject: [PATCH 054/101] feat(drill-converter): Edit entity identities Repurpose entity preview actions so label rules and direct label/symbol edits are separate operations. Ignore only the repository-root app.json local file. --- .gitignore | 1 + .../src/components/preview-section.tsx | 175 +++++++++++++++++- apps/drill-converter/src/converter-screen.tsx | 5 +- .../src/converter/__tests__/settings.test.ts | 42 +++++ .../drill-converter/src/converter/settings.ts | 57 +++++- .../src/converter/use-converter-controller.ts | 54 +++++- 6 files changed, 319 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index c40e195f..1435567b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ coverage/clover.xml # other .com.facebook.react.devsupport.BundleDownloader.swp +/app.json # local vendor reference docs (do not commit) docs/local-only diff --git a/apps/drill-converter/src/components/preview-section.tsx b/apps/drill-converter/src/components/preview-section.tsx index 0474b8b6..6441ff51 100644 --- a/apps/drill-converter/src/components/preview-section.tsx +++ b/apps/drill-converter/src/components/preview-section.tsx @@ -5,11 +5,12 @@ import { drillSetSchema, formatSetName, type DrillDocument, + type DrillEntity, type DrillSet, } from "@eight2five/drill-schema"; import { Divider } from "@eight2five/ui/components/divider"; import { Icon } from "@eight2five/ui/components/icon"; -import { Pencil } from "lucide-react-native"; +import { ListPlus, Pencil } from "lucide-react-native"; import { FormField, @@ -25,7 +26,8 @@ export function PreviewSection({ settingsErrors, summary, canDownload, - onEditEntityLabel, + onAddEntityLabelRule, + onUpdateEntityIdentity, onUpdateSet, onDownload, }: { @@ -40,10 +42,14 @@ export function PreviewSection({ readonly positions: number; }; readonly canDownload: boolean; - readonly onEditEntityLabel: (label: string) => void; + readonly onAddEntityLabelRule: (label: string) => void; + readonly onUpdateEntityIdentity: ( + entity: Pick, + ) => string | undefined; readonly onUpdateSet: (set: DrillSet) => string | undefined; readonly onDownload: () => void; }) { + const [editingEntity, setEditingEntity] = React.useState(); const [editingSet, setEditingSet] = React.useState(); const diagnostics = importResult?.diagnostics ?? []; const errors = [ @@ -104,8 +110,10 @@ export function PreviewSection({ {index > 0 ? : null} onEditEntityLabel(entity.label)} + addRuleLabel={`Add label rule for ${entity.label}`} + onAddRule={() => onAddEntityLabelRule(entity.label)} + editLabel={`Edit label and symbol for ${entity.label}`} + onEdit={() => setEditingEntity(entity)} /> ))} @@ -159,6 +167,15 @@ export function PreviewSection({ /> + {editingEntity ? ( + setEditingEntity(undefined)} + onSave={onUpdateEntityIdentity} + /> + ) : null} + {editingSet ? ( void; readonly editLabel: string; readonly onEdit: () => void; }) { @@ -278,6 +299,24 @@ function PreviewRow({ > {text} + {onAddRule && addRuleLabel ? ( + ({ + width: 30, + height: 30, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + backgroundColor: pressed ? colors.accentSoft : "transparent", + })} + > + + + ) : null} void; + readonly onSave: ( + entity: Pick, + ) => string | undefined; +}) { + const [label, setLabel] = React.useState(entity.label); + const [symbol, setSymbol] = React.useState(entity.symbol); + const [error, setError] = React.useState(); + + const save = () => { + const trimmedLabel = label.trim(); + const trimmedSymbol = symbol.trim(); + if (!trimmedLabel) { + setError("Entity label cannot be empty."); + return; + } + if (!trimmedSymbol || trimmedSymbol.length > 16) { + setError("Entity symbol must be 1-16 characters."); + return; + } + + const documentError = onSave({ + id: entity.id, + label: trimmedLabel, + symbol: trimmedSymbol, + }); + if (documentError) { + setError(documentError); + return; + } + onClose(); + }; + + return ( + + + + + + Edit entity {entity.label} + + + Change this entity's explicit label or symbol. Its numeric ID + stays {entity.id}. + + + + + + + + + {error ? ( + + {error} + + ) : null} + + + + + + + + + + + ); +} + function SetEditModal({ set, onClose, diff --git a/apps/drill-converter/src/converter-screen.tsx b/apps/drill-converter/src/converter-screen.tsx index 23cbb637..925c8296 100644 --- a/apps/drill-converter/src/converter-screen.tsx +++ b/apps/drill-converter/src/converter-screen.tsx @@ -16,7 +16,7 @@ export function ConverterScreen() { const [rulesFocusRequestKey, setRulesFocusRequestKey] = React.useState(0); const { width } = useWindowDimensions(); - const editEntityLabel = React.useCallback( + const addEntityLabelRule = React.useCallback( (label: string) => { addLabelOverride(label); setRulesFocusRequestKey((key) => key + 1); @@ -107,7 +107,8 @@ export function ConverterScreen() { settingsErrors={controller.settingsErrors} summary={controller.summary} canDownload={controller.canDownload} - onEditEntityLabel={editEntityLabel} + onAddEntityLabelRule={addEntityLabelRule} + onUpdateEntityIdentity={controller.updateEntityIdentity} onUpdateSet={controller.updateSet} onDownload={controller.download} /> diff --git a/apps/drill-converter/src/converter/__tests__/settings.test.ts b/apps/drill-converter/src/converter/__tests__/settings.test.ts index 5bb626d4..25300854 100644 --- a/apps/drill-converter/src/converter/__tests__/settings.test.ts +++ b/apps/drill-converter/src/converter/__tests__/settings.test.ts @@ -140,6 +140,48 @@ describe("drill converter settings", () => { }); }); + test("applies entity label and symbol edits before resolving rules", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + entityOverrides: [{ id: 1, label: "T1", symbol: "T" }], + rules: [ + { + ...createEmptyRuleDraft("edited-label-rule"), + target: "label" as const, + key: "T1", + name: "Edited Entity", + }, + ], + }; + const validation = validateConverterSettings(settings); + expect(validation.errors).toEqual([]); + + const result = applyConverterSettings(source, settings, validation); + expect(result.entities[0]).toMatchObject({ + id: 1, + label: "T1", + symbol: "T", + }); + expect( + resolveDrillEntity(result.entities[0], result.entityRules), + ).toMatchObject({ name: "Edited Entity" }); + }); + + test("rejects invalid entity identity overrides", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + entityOverrides: [{ id: 1, label: " ", symbol: "ABCDEFGHIJKLMNOPQ" }], + }; + expect(validateConverterSettings(settings).errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("label cannot be empty"), + expect.stringContaining("symbol must be 1-16 characters"), + ]), + ); + }); + test("defaults prop rules to 1 by 1 8-to-5 steps", () => { const rule = { ...createEmptyRuleDraft("prop-rule"), diff --git a/apps/drill-converter/src/converter/settings.ts b/apps/drill-converter/src/converter/settings.ts index 90fd3eac..77b03dd6 100644 --- a/apps/drill-converter/src/converter/settings.ts +++ b/apps/drill-converter/src/converter/settings.ts @@ -8,6 +8,7 @@ import { parseDrillDocument, resolveEntityRuleValues, type DrillDocument, + type DrillEntity, type DrillEntityType, type DrillPath, type DrillSet, @@ -72,6 +73,11 @@ export interface EntityRuleDraft { readonly labelVisibility: LabelVisibility; } +export type EntityIdentityOverride = Pick< + DrillEntity, + "id" | "label" | "symbol" +>; + export interface ConverterSettings { readonly title: string; readonly drillWriter: string; @@ -81,6 +87,7 @@ export interface ConverterSettings { readonly fieldMode: FieldPresetId | "custom"; readonly customFieldJson: string; readonly rules: readonly EntityRuleDraft[]; + readonly entityOverrides: readonly EntityIdentityOverride[]; readonly setOverrides: readonly DrillSet[]; readonly includeSourceReferences: boolean; readonly explicitStraightPaths: boolean; @@ -102,6 +109,7 @@ export function createDefaultConverterSettings(): ConverterSettings { fieldMode: "football-nfhs", customFieldJson: createDefaultCustomFieldJson(), rules: [], + entityOverrides: [], setOverrides: [], includeSourceReferences: true, explicitStraightPaths: false, @@ -175,6 +183,27 @@ export function validateConverterSettings( } const entityRules = buildEntityRules(settings.rules, errors); + const seenEntityOverrideIds = new Set(); + for (const [index, override] of settings.entityOverrides.entries()) { + if (seenEntityOverrideIds.has(override.id)) { + errors.push(`Duplicate entity override for entity id ${override.id}.`); + continue; + } + seenEntityOverrideIds.add(override.id); + if (!Number.isSafeInteger(override.id) || override.id < 0) { + errors.push(`Entity override ${index + 1} has an invalid entity id.`); + } + if (!override.label.trim()) { + errors.push(`Entity override ${index + 1} label cannot be empty.`); + } + const symbol = override.symbol.trim(); + if (!symbol || symbol.length > 16) { + errors.push( + `Entity override ${index + 1} symbol must be 1-16 characters.`, + ); + } + } + const seenSetOverrideIds = new Set(); for (const [index, set] of settings.setOverrides.entries()) { if (seenSetOverrideIds.has(set.id)) { @@ -208,14 +237,34 @@ export function applyConverterSettings( throw new Error(validation.errors[0] ?? "Converter settings are invalid."); } + const entityOverrides = new Map( + settings.entityOverrides.map( + (override) => [override.id, override] as const, + ), + ); const entities = source.entities.map((entity) => { - const ruleValues = resolveEntityRuleValues(entity, validation.entityRules); - const type = ruleValues.type ?? entity.type; + const identityOverride = entityOverrides.get(entity.id); + const entityWithIdentity = identityOverride + ? { + ...entity, + label: identityOverride.label.trim(), + symbol: identityOverride.symbol.trim(), + } + : entity; + const ruleValues = resolveEntityRuleValues( + entityWithIdentity, + validation.entityRules, + ); + const type = ruleValues.type ?? entityWithIdentity.type; if (type === "prop") { - const { section: _section, instrument: _instrument, ...rest } = entity; + const { + section: _section, + instrument: _instrument, + ...rest + } = entityWithIdentity; return { ...rest, type }; } - const { size: _size, ...rest } = entity; + const { size: _size, ...rest } = entityWithIdentity; return { ...rest, type }; }); const setOverrides = new Map( diff --git a/apps/drill-converter/src/converter/use-converter-controller.ts b/apps/drill-converter/src/converter/use-converter-controller.ts index fe74dfdb..25aafff9 100644 --- a/apps/drill-converter/src/converter/use-converter-controller.ts +++ b/apps/drill-converter/src/converter/use-converter-controller.ts @@ -20,6 +20,7 @@ import { inferTitleFromFileName, validateConverterSettings, type ConverterSettings, + type EntityIdentityOverride, type EntityRuleDraft, } from "./settings"; import { extractPdfText, getPdfJsVersion } from "../pdf/pdf-text-extractor.web"; @@ -90,13 +91,16 @@ export function useConverterController() { const availableSymbols = React.useMemo( () => Array.from( - new Set( - (importResult?.sheets ?? []) + new Set([ + ...(importResult?.sheets ?? []) .map((sheet) => sheet.sourceSymbol.trim()) .filter(Boolean), - ), + ...(outputDocument?.entities ?? []) + .map((entity) => entity.symbol.trim()) + .filter(Boolean), + ]), ).sort((left, right) => left.localeCompare(right)), - [importResult?.sheets], + [importResult?.sheets, outputDocument?.entities], ); const pickPdf = React.useCallback(async () => { @@ -118,6 +122,7 @@ export function useConverterController() { ...current, title: inferTitleFromFileName(nextAsset.name), rules: [], + entityOverrides: [], setOverrides: [], })); @@ -150,6 +155,7 @@ export function useConverterController() { ...current, title: "", rules: [], + entityOverrides: [], setOverrides: [], })); }, []); @@ -226,6 +232,45 @@ export function useConverterController() { })); }, []); + const updateEntityIdentity = React.useCallback( + (override: EntityIdentityOverride): string | undefined => { + if (!importResult?.document) + return "Parse a PDF before editing entities."; + if ( + !importResult.document.entities.some( + (entity) => entity.id === override.id, + ) + ) { + return `Unknown entity id ${override.id}.`; + } + + const nextSettings: ConverterSettings = { + ...settings, + entityOverrides: [ + ...settings.entityOverrides.filter( + (entity) => entity.id !== override.id, + ), + { + ...override, + label: override.label.trim(), + symbol: override.symbol.trim(), + }, + ], + }; + const validation = validateConverterSettings(nextSettings); + try { + applyConverterSettings(importResult.document, nextSettings, validation); + } catch (cause) { + return cause instanceof Error + ? cause.message + : "The entity edit is invalid."; + } + setSettings(nextSettings); + return undefined; + }, + [importResult, settings], + ); + const updateSet = React.useCallback( (setOverride: DrillDocument["sets"][number]): string | undefined => { if (!importResult?.document) return "Parse a PDF before editing sets."; @@ -284,6 +329,7 @@ export function useConverterController() { addLabelOverride, updateRule, removeRule, + updateEntityIdentity, updateSet, download, }; From d34aec9e9af0c865204871964a03f58325b9d4b7 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 02:55:09 -0500 Subject: [PATCH 055/101] chore(expo): Align SDK dependencies Update Expo, Expo Router, and Expo UI to the SDK 57 compatible patch versions and keep workspace manifests synchronized. --- apps/drill-converter/package.json | 4 +- apps/mobile/package.json | 6 +-- apps/testbed/package.json | 4 +- package-lock.json | 88 +++++++++++++++---------------- packages/mobile/package.json | 2 +- 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/apps/drill-converter/package.json b/apps/drill-converter/package.json index c2f6bc2c..346712d3 100644 --- a/apps/drill-converter/package.json +++ b/apps/drill-converter/package.json @@ -17,9 +17,9 @@ "@eight2five/drill-schema": "0.0.0", "@eight2five/ui": "0.0.0", "@expo/metro-runtime": "~57.0.8", - "expo": "~57.0.9", + "expo": "~57.0.10", "expo-document-picker": "~57.0.1", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "lucide-react-native": "^1.22.0", "react": "19.2.3", "react-dom": "^19.2.3", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b4207c8b..36407992 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,10 +20,10 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "@expo/ui": "~57.0.8", - "expo": "~57.0.9", + "@expo/ui": "~57.0.9", + "expo": "~57.0.10", "expo-dev-client": "~57.0.10", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0" diff --git a/apps/testbed/package.json b/apps/testbed/package.json index f9e77719..504bcc26 100644 --- a/apps/testbed/package.json +++ b/apps/testbed/package.json @@ -20,9 +20,9 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "expo": "~57.0.9", + "expo": "~57.0.10", "expo-dev-client": "~57.0.10", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0" diff --git a/package-lock.json b/package-lock.json index 76de6dfc..cb2a4b46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,9 +29,9 @@ "@eight2five/drill-schema": "0.0.0", "@eight2five/ui": "0.0.0", "@expo/metro-runtime": "~57.0.8", - "expo": "~57.0.9", + "expo": "~57.0.10", "expo-document-picker": "~57.0.1", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "lucide-react-native": "^1.22.0", "react": "19.2.3", "react-dom": "^19.2.3", @@ -96,10 +96,10 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "@expo/ui": "~57.0.8", - "expo": "~57.0.9", + "@expo/ui": "~57.0.9", + "expo": "~57.0.10", "expo-dev-client": "~57.0.10", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0" @@ -196,9 +196,9 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "expo": "~57.0.9", + "expo": "~57.0.10", "expo-dev-client": "~57.0.10", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0" @@ -2414,9 +2414,9 @@ "license": "MIT AND OFL-1.1" }, "node_modules/@expo/cli": { - "version": "57.0.11", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.11.tgz", - "integrity": "sha512-ENXCwRyL8Q9qbabUmm0L6w9kXTmdGotW7eDEEWmUDXLPux+nEzNDqw0MzWQ5K3ZsXS51QmUJZg5yETB+6SnNRg==", + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz", + "integrity": "sha512-mmOcZJmyEDYtEtHr5e4mBr8O+Y2WBSIhqY8PL5uY3EjQts/5vxKNBYuUqIknGUvZHGpdIjAQ1IjaPNQiAf8uPQ==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -2436,7 +2436,7 @@ "@expo/plist": "^0.8.1", "@expo/prebuild-config": "^57.0.10", "@expo/require-utils": "^57.0.4", - "@expo/router-server": "^57.0.4", + "@expo/router-server": "^57.0.5", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", @@ -2978,17 +2978,17 @@ } }, "node_modules/@expo/router-server": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.4.tgz", - "integrity": "sha512-ucqCP0hK8nZb9+S8QJYdQxNCkfRClzkKdg2RpWYDKDYgRIHyoRyxEDRgDgvbhH+q78yfY83abU8OTx8MMOrf1g==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.5.tgz", + "integrity": "sha512-vke39l0bo3H2q9JB/KXpAJ7HpscdTG3Mktbxanc8yn3riWzzSsnv0uxwZGZCrZrnDQzFxlLTXgbZrGkU26ng1w==", "license": "MIT", "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { - "@expo/metro-runtime": "^57.0.7", + "@expo/metro-runtime": "^57.0.8", "expo": "*", - "expo-constants": "^57.0.7", + "expo-constants": "^57.0.9", "expo-font": "^57.0.1", "expo-router": "*", "expo-server": "^57.0.1", @@ -3040,9 +3040,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.8.tgz", - "integrity": "sha512-aR17CRM45k4JyFahX4Xn2b6ti3aiddC4SAzvsm3iUCVacQA6tGmClu012rYNeVXhJ96csX2o7EEymfzYqgJNxw==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.9.tgz", + "integrity": "sha512-VIxvk5ncgylBj2vrIP1iLaMc3XmYucKbf0hIcg3qx9l2anB9JzaYnH7cvVgNU3RfwV8R9m/tA7lX9BP7D8uMQw==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -6631,9 +6631,9 @@ } }, "node_modules/agent-cli-detector": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.4.tgz", - "integrity": "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.5.tgz", + "integrity": "sha512-6xvLw0EGPuxoYGZeqyMV4AK+WoZ31jsqb7a5pln1BTf6oDFsrgvfCJT7E7y9oAqifJZhJ3fZ0J36H7o6JzrB0Q==", "license": "MIT", "bin": { "agent-cli-detector": "dist/cli.js" @@ -9733,13 +9733,13 @@ } }, "node_modules/expo": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.9.tgz", - "integrity": "sha512-NDEvnU+vjRdMbtOyDC25OSok9/E97al97pyx+bMphQgNRGAED0D7oF8ha7oGYsLE+7jFzpPADVM9S60dGbpHIA==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.10.tgz", + "integrity": "sha512-nirZEdsA4ZKkzOZX3sqzpArc2tnVAvdiFmm0gZRmIckl0FnSgC3RGp27JJZ2NzqkEaVZ5e06dFvX/NECdK5cIQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.11", + "@expo/cli": "^57.0.12", "@expo/config": "~57.0.6", "@expo/config-plugins": "~57.0.6", "@expo/devtools": "~57.0.1", @@ -9752,12 +9752,12 @@ "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.5", "expo-asset": "~57.0.8", - "expo-constants": "~57.0.8", + "expo-constants": "~57.0.9", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.9", - "expo-modules-core": "~57.0.8", + "expo-modules-core": "~57.0.9", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -9905,9 +9905,9 @@ } }, "node_modules/expo-constants": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.8.tgz", - "integrity": "sha512-ts+B7E5076BtkblrKGy7+Sm/R2obHfTwqhmYQVYbWRtilv3+C/xNwHZhyNEnAvzM/JjKqx7UdaITUBYaeFreZw==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.9.tgz", + "integrity": "sha512-Y47sGiF+U8fwicUSPdJPjB27PuU+FgLK4Mpai0ksZF5hv8jNN3HBqKuDNxsiu70uHBKf80TaR4gwMPh0pmETiQ==", "license": "MIT", "dependencies": { "@expo/env": "~2.4.2" @@ -10342,9 +10342,9 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.8.tgz", - "integrity": "sha512-ZUU943Oso3B8jFggC+2+LOk8oa+im66IQlleYay6WP5iexbdF2fqpgC4BcYsN4xhCkDKh7KDINSt1FlcxyRRjQ==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.9.tgz", + "integrity": "sha512-8UL/70VjmN8jOG4Tugdq6JX63Za6Qnri8h5dqFuLsDL2JxmTSB/Fz7fyEhDSpjlxVFyiJo5uF2aYVrz7Vxj2ZQ==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", @@ -10414,15 +10414,15 @@ } }, "node_modules/expo-router": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.9.tgz", - "integrity": "sha512-jPBwHmKmBfzCLUuKgFf+I/UhFkxvS3J2WF5MQKcuupC8XzQPBAqdzbxmcPIDY3VIVabHw7WnhwzsROWA74t1LA==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.10.tgz", + "integrity": "sha512-E6Cjudl4wQ86KdEHqLGhBmfOFRdtzXTtRzEEDmqOvqtkAc9C637u0us7CGKkkee9m+kwuHqfhn779ww85rn/Pg==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.8", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.8", + "@expo/ui": "^57.0.9", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -10454,8 +10454,8 @@ "@expo/metro-runtime": "^57.0.8", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^57.0.8", - "expo-linking": "^57.0.4", + "expo-constants": "^57.0.9", + "expo-linking": "^57.0.5", "react": "*", "react-dom": "*", "react-native": "*", @@ -14778,9 +14778,9 @@ "license": "MIT" }, "node_modules/multitars": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", - "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.1.tgz", + "integrity": "sha512-Do9rHaSDfQ2Fk5Dpg2RjQ4M48Ol7rSq1gvKn/dXJYnhKEm8RRjjUvyiGDDEhJqb0hJPGjRaTB1kx6beqKO/i6Q==", "license": "MIT" }, "node_modules/nanoid": { @@ -19115,7 +19115,7 @@ "expo-notifications": "~57.0.7", "expo-pans-ble-api": "file:../../modules/expo-pans-ble-api", "expo-print": "~57.0.1", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "expo-screen-capture": "~57.0.1", "expo-screen-orientation": "~57.0.1", "expo-secure-store": "~57.0.1", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 60964e38..460f96a9 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -67,7 +67,7 @@ "expo-notifications": "~57.0.7", "expo-pans-ble-api": "file:../../modules/expo-pans-ble-api", "expo-print": "~57.0.1", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "expo-screen-capture": "~57.0.1", "expo-screen-orientation": "~57.0.1", "expo-secure-store": "~57.0.1", From 8c781652aa189a408e741d37e31c6482bf3c2e40 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 02:58:59 -0500 Subject: [PATCH 056/101] chore(expo): Align linking peer version Update expo-linking to the patch required by Expo Router so the shared mobile dependency tree has no invalid peer resolution. --- package-lock.json | 10 +++++----- packages/mobile/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index cb2a4b46..fab55d88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10119,12 +10119,12 @@ } }, "node_modules/expo-linking": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.4.tgz", - "integrity": "sha512-e1alfHNJdywIfJkCuKMc6M3hBfAGPd2gKMeF/6V7qwFWzHCS2mTBqU+KaO4FLpltA5Nt6CYEx6zmUlGfUF+8lA==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.5.tgz", + "integrity": "sha512-SmJI3wr0EVfeKPGf+Qgr9gUbrXk8mM0ATqYLvAX/EAzawDjohPzMJ5pTt7TYMX0Wknj4XCtyCQrGhnERMXT6cQ==", "license": "MIT", "dependencies": { - "expo-constants": "~57.0.7", + "expo-constants": "~57.0.9", "invariant": "^2.2.4" }, "peerDependencies": { @@ -19107,7 +19107,7 @@ "expo-image-picker": "~57.0.6", "expo-keep-awake": "~57.0.1", "expo-linear-gradient": "~57.0.1", - "expo-linking": "~57.0.4", + "expo-linking": "~57.0.5", "expo-local-authentication": "~57.0.2", "expo-location": "~57.0.6", "expo-media-library": "~57.0.3", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 460f96a9..79d828bd 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -59,7 +59,7 @@ "expo-image-picker": "~57.0.6", "expo-keep-awake": "~57.0.1", "expo-linear-gradient": "~57.0.1", - "expo-linking": "~57.0.4", + "expo-linking": "~57.0.5", "expo-local-authentication": "~57.0.2", "expo-location": "~57.0.6", "expo-media-library": "~57.0.3", From c1db4fb358019c80cd6dd14dc2481f96054f29d5 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 03:19:14 -0500 Subject: [PATCH 057/101] feat(settings): Add theme and field display preferences Persist appearance, terminology, field display, marker, distance, and interpolation settings for the mobile MVP. Use the native Expo UI picker and keep navigation, Gluestack, and shared theme colors on one effective appearance. --- apps/mobile/app/_layout.tsx | 45 ++- .../settings/developer-settings-screen.tsx | 4 +- .../features/settings/settings-components.tsx | 76 ++--- .../src/features/settings/settings-screen.tsx | 58 +++- .../__tests__/app-settings-store.test.ts | 4 +- .../state/__tests__/appearance-theme.test.ts | 21 ++ .../src/settings/SqliteSettingsRepository.ts | 126 +++++++- .../src/settings/__tests__/repository.test.ts | 283 ++++++++++++++++-- packages/mobile/src/settings/types.ts | 143 ++++++++- .../storage/__tests__/mobileDatabase.test.ts | 22 +- packages/mobile/src/storage/mobileDatabase.ts | 49 ++- packages/ui/components/button/index.tsx | 9 +- packages/ui/theme/index.tsx | 44 ++- 13 files changed, 763 insertions(+), 121 deletions(-) create mode 100644 apps/mobile/src/state/__tests__/appearance-theme.test.ts diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index d94ab4ad..cfa6712c 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -2,11 +2,15 @@ import React from "react"; import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; -import { useColorScheme } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GluestackUIProvider } from "@eight2five/ui/components/gluestack-ui-provider"; -import { useEight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; +import { + Eight2FiveThemeProvider, + useEight2FiveFonts, + useEight2FiveTheme, + useEight2FiveThemeName, +} from "@eight2five/ui/theme"; import { TabBarVisibilityProvider } from "../src/navigation/tab-bar-visibility-context"; import { useMobileOrientationLock } from "../src/navigation/use-mobile-orientation-lock"; @@ -25,7 +29,6 @@ void SplashScreen.preventAutoHideAsync(); export default function MobileRootLayout() { const [fontsLoaded, fontError] = useEight2FiveFonts(); - const theme = useEight2FiveTheme(); React.useEffect(() => { if (fontsLoaded || fontError) void SplashScreen.hideAsync(); @@ -35,36 +38,48 @@ export default function MobileRootLayout() { return ( - - - + + + - + - - - + + + ); } -function MobileNavigation({ backgroundColor }: { backgroundColor: string }) { +function MobileAppearance({ children }: { children: React.ReactNode }) { + const { settings } = useAppSettingsSnapshot(); + return ( + + + {children} + + + ); +} + +function MobileNavigation() { useMobileOrientationLock(); const { settings } = useAppSettingsSnapshot(); - const colorScheme = useColorScheme(); + const theme = useEight2FiveTheme(); + const themeName = useEight2FiveThemeName(); return ( - + - + ); diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx index dde18fef..26d51551 100644 --- a/apps/mobile/src/features/settings/developer-settings-screen.tsx +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useRouter } from "expo-router"; import { Activity, - CircleDashed, + CircleDotDashed, Code2, Database, Grid3X3, @@ -236,7 +236,7 @@ export function DeveloperSettingsScreen() { testID="show-cached-anchor-geometry-setting" /> ({ testID?: string; }) { const theme = useEight2FiveTheme(); + const themeName = useEight2FiveThemeName(); return ( - + {choices.map((choice) => ( + + ))} + + ); } diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 452acc14..62ed9d9d 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -1,19 +1,24 @@ import React from "react"; import { useRouter } from "expo-router"; import { + BookOpenText, Code2, Eye, ListChecks, Map, Navigation, + Palette, Radio, Route, + Rows3, } from "lucide-react-native"; import type { + AppearanceMode, AppSettingsUpdate, FieldPerspective, TransitionMetricMode, } from "@eight2five/mobile/settings"; +import type { DrillTerminology } from "@eight2five/mobile/drill"; import { FIELD_PRESET_IDS, getFieldPreset, @@ -43,6 +48,17 @@ const PERSPECTIVE_CHOICES = [ { label: "Performer", value: "performer" }, ] as const; +const APPEARANCE_CHOICES = [ + { label: "System", value: "system" }, + { label: "Light", value: "light" }, + { label: "Dark", value: "dark" }, +] as const; + +const TERMINOLOGY_CHOICES = [ + { label: "Sets", value: "sets" }, + { label: "Pages", value: "pages" }, +] as const; + const FIELD_PRESET_CHOICES = FIELD_PRESET_IDS.map((value) => ({ label: getFieldPreset(value).name, value, @@ -50,7 +66,7 @@ const FIELD_PRESET_CHOICES = FIELD_PRESET_IDS.map((value) => ({ const TRANSITION_CHOICES = [ { label: "Step Size", value: "step-size" }, - { label: "Crossing Counts", value: "crossing-counts" }, + { label: "xCounts", value: "crossing-counts" }, ] as const; export function SettingsScreen() { @@ -91,6 +107,19 @@ export function SettingsScreen() { ) : null} + + + icon={Palette} + title="App appearance" + description="Follow the system appearance or always use a light or dark theme." + value={settings.appearanceMode} + choices={APPEARANCE_CHOICES} + onChange={(appearanceMode) => void update({ appearanceMode })} + disabled={disabled} + testID="appearance-mode-setting" + /> + + + + icon={BookOpenText} + title="Drill terminology" + description="Choose whether drill positions are called Sets or Pages." + value={settings.drillTerminology} + choices={TERMINOLOGY_CHOICES} + onChange={(drillTerminology) => void update({ drillTerminology })} + disabled={disabled} + testID="drill-terminology-setting" + /> @@ -135,20 +174,31 @@ export function SettingsScreen() { icon={Eye} title="Field perspective" - description="Choose the default semantic field view." + description="Choose how the field is oriented." value={settings.fieldPerspective} choices={PERSPECTIVE_CHOICES} onChange={(fieldPerspective) => void update({ fieldPerspective })} disabled={disabled} testID="field-perspective-setting" /> + + void update({ showAuxiliaryFieldMarks }) + } + disabled={disabled} + testID="auxiliary-field-marks-setting" + /> icon={Route} - title="Transition metric" - description="Show Step Size or yard-line crossing counts." + title="Step size metric" + description="Show Step Size or xCounts." value={settings.transitionMetricMode} choices={TRANSITION_CHOICES} onChange={(transitionMetricMode) => diff --git a/apps/mobile/src/state/__tests__/app-settings-store.test.ts b/apps/mobile/src/state/__tests__/app-settings-store.test.ts index 85cb1765..ff0ff4db 100644 --- a/apps/mobile/src/state/__tests__/app-settings-store.test.ts +++ b/apps/mobile/src/state/__tests__/app-settings-store.test.ts @@ -62,11 +62,11 @@ describe("AppSettingsStore", () => { await Promise.all([ store.update({ guidanceEnabled: false }), - store.update({ fieldPerspective: "performer" }), + store.update({ appearanceMode: "dark" }), ]); expect(settingsRepository.update.mock.calls).toEqual([ [{ guidanceEnabled: false }], - [{ fieldPerspective: "performer" }], + [{ appearanceMode: "dark" }], ]); await store.resetPreferences(); diff --git a/apps/mobile/src/state/__tests__/appearance-theme.test.ts b/apps/mobile/src/state/__tests__/appearance-theme.test.ts new file mode 100644 index 00000000..057489a5 --- /dev/null +++ b/apps/mobile/src/state/__tests__/appearance-theme.test.ts @@ -0,0 +1,21 @@ +import { + eight2FiveThemes, + resolveEight2FiveThemeName, +} from "@eight2five/ui/theme"; + +describe("app appearance", () => { + test("uses the OS appearance only in system mode", () => { + expect(resolveEight2FiveThemeName("system", "light")).toBe("light"); + expect(resolveEight2FiveThemeName("system", "dark")).toBe("dark"); + expect(resolveEight2FiveThemeName("system", null)).toBe("light"); + expect(resolveEight2FiveThemeName("system", "unspecified")).toBe("light"); + }); + + test("light and dark modes override the OS appearance", () => { + expect(resolveEight2FiveThemeName("light", "dark")).toBe("light"); + expect(resolveEight2FiveThemeName("dark", "light")).toBe("dark"); + expect(eight2FiveThemes.light.background).not.toBe( + eight2FiveThemes.dark.background, + ); + }); +}); diff --git a/packages/mobile/src/settings/SqliteSettingsRepository.ts b/packages/mobile/src/settings/SqliteSettingsRepository.ts index 101c6877..bbae23eb 100644 --- a/packages/mobile/src/settings/SqliteSettingsRepository.ts +++ b/packages/mobile/src/settings/SqliteSettingsRepository.ts @@ -38,12 +38,13 @@ export class SqliteSettingsRepository implements AppSettingsRepository { async resetPreferences(): Promise { await this.load(); - // Deliberately omit both selection columns. The legacy physical - // drill_terminology column is pinned to "sets" and is no longer exposed. + // Deliberately omit both selection columns so resetPreferences preserves + // the user's active drill and selected set. await this.db.runAsync( `UPDATE ${APP_SETTINGS_TABLE} - SET drill_features_enabled = ?, - drill_terminology = 'sets', + SET appearance_mode = ?, + drill_features_enabled = ?, + drill_terminology = ?, field_perspective = ?, default_field_preset = ?, transition_metric_mode = ?, @@ -52,10 +53,24 @@ export class SqliteSettingsRepository implements AppSettingsRepository { show_cached_anchor_geometry = ?, show_comfortable_anchor_range = ?, show_perimeter_step_grid = ?, + show_auxiliary_field_marks = ?, + show_performer_labels = ?, + show_performer_names = ?, + show_prop_labels = ?, + show_prop_names = ?, + show_transition_markers = ?, + show_all_transition_sets = ?, + previous_transition_set_count = ?, + next_transition_set_count = ?, + distance_green_threshold_steps = ?, + distance_yellow_threshold_steps = ?, + motion_interpolation_enabled = ?, comfortable_anchor_range_meters = ? WHERE singleton_id = ?`, [ + DEFAULT_APP_SETTINGS.appearanceMode, boolToSql(DEFAULT_APP_SETTINGS.drillFeaturesEnabled), + DEFAULT_APP_SETTINGS.drillTerminology, DEFAULT_APP_SETTINGS.fieldPerspective, DEFAULT_APP_SETTINGS.defaultFieldPreset, DEFAULT_APP_SETTINGS.transitionMetricMode, @@ -64,6 +79,18 @@ export class SqliteSettingsRepository implements AppSettingsRepository { boolToSql(DEFAULT_APP_SETTINGS.showCachedAnchorGeometry), boolToSql(DEFAULT_APP_SETTINGS.showComfortableAnchorRange), boolToSql(DEFAULT_APP_SETTINGS.showPerimeterStepGrid), + boolToSql(DEFAULT_APP_SETTINGS.showAuxiliaryFieldMarks), + boolToSql(DEFAULT_APP_SETTINGS.showPerformerLabels), + boolToSql(DEFAULT_APP_SETTINGS.showPerformerNames), + boolToSql(DEFAULT_APP_SETTINGS.showPropLabels), + boolToSql(DEFAULT_APP_SETTINGS.showPropNames), + boolToSql(DEFAULT_APP_SETTINGS.showTransitionMarkers), + boolToSql(DEFAULT_APP_SETTINGS.showAllTransitionSets), + DEFAULT_APP_SETTINGS.previousTransitionSetCount, + DEFAULT_APP_SETTINGS.nextTransitionSetCount, + DEFAULT_APP_SETTINGS.distanceGreenThresholdSteps, + DEFAULT_APP_SETTINGS.distanceYellowThresholdSteps, + boolToSql(DEFAULT_APP_SETTINGS.motionInterpolationEnabled), DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, 1, ], @@ -74,6 +101,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { private async readRow(): Promise { return await this.db.getFirstAsync( `SELECT + appearance_mode, drill_features_enabled, drill_terminology, field_perspective, @@ -84,6 +112,18 @@ export class SqliteSettingsRepository implements AppSettingsRepository { show_cached_anchor_geometry, show_comfortable_anchor_range, show_perimeter_step_grid, + show_auxiliary_field_marks, + show_performer_labels, + show_performer_names, + show_prop_labels, + show_prop_names, + show_transition_markers, + show_all_transition_sets, + previous_transition_set_count, + next_transition_set_count, + distance_green_threshold_steps, + distance_yellow_threshold_steps, + motion_interpolation_enabled, comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id @@ -98,6 +138,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { await this.db.runAsync( `INSERT INTO ${APP_SETTINGS_TABLE} ( singleton_id, + appearance_mode, drill_features_enabled, drill_terminology, field_perspective, @@ -108,13 +149,26 @@ export class SqliteSettingsRepository implements AppSettingsRepository { show_cached_anchor_geometry, show_comfortable_anchor_range, show_perimeter_step_grid, + show_auxiliary_field_marks, + show_performer_labels, + show_performer_names, + show_prop_labels, + show_prop_names, + show_transition_markers, + show_all_transition_sets, + previous_transition_set_count, + next_transition_set_count, + distance_green_threshold_steps, + distance_yellow_threshold_steps, + motion_interpolation_enabled, comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id - ) VALUES (?, ?, 'sets', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton_id) DO UPDATE SET + appearance_mode = excluded.appearance_mode, drill_features_enabled = excluded.drill_features_enabled, - drill_terminology = 'sets', + drill_terminology = excluded.drill_terminology, field_perspective = excluded.field_perspective, default_field_preset = excluded.default_field_preset, transition_metric_mode = excluded.transition_metric_mode, @@ -123,12 +177,26 @@ export class SqliteSettingsRepository implements AppSettingsRepository { show_cached_anchor_geometry = excluded.show_cached_anchor_geometry, show_comfortable_anchor_range = excluded.show_comfortable_anchor_range, show_perimeter_step_grid = excluded.show_perimeter_step_grid, + show_auxiliary_field_marks = excluded.show_auxiliary_field_marks, + show_performer_labels = excluded.show_performer_labels, + show_performer_names = excluded.show_performer_names, + show_prop_labels = excluded.show_prop_labels, + show_prop_names = excluded.show_prop_names, + show_transition_markers = excluded.show_transition_markers, + show_all_transition_sets = excluded.show_all_transition_sets, + previous_transition_set_count = excluded.previous_transition_set_count, + next_transition_set_count = excluded.next_transition_set_count, + distance_green_threshold_steps = excluded.distance_green_threshold_steps, + distance_yellow_threshold_steps = excluded.distance_yellow_threshold_steps, + motion_interpolation_enabled = excluded.motion_interpolation_enabled, comfortable_anchor_range_meters = excluded.comfortable_anchor_range_meters, active_drill_id = excluded.active_drill_id, selected_drill_page_id = excluded.selected_drill_page_id`, [ 1, + normalized.appearanceMode, boolToSql(normalized.drillFeaturesEnabled), + normalized.drillTerminology, normalized.fieldPerspective, normalized.defaultFieldPreset, normalized.transitionMetricMode, @@ -137,6 +205,18 @@ export class SqliteSettingsRepository implements AppSettingsRepository { boolToSql(normalized.showCachedAnchorGeometry), boolToSql(normalized.showComfortableAnchorRange), boolToSql(normalized.showPerimeterStepGrid), + boolToSql(normalized.showAuxiliaryFieldMarks), + boolToSql(normalized.showPerformerLabels), + boolToSql(normalized.showPerformerNames), + boolToSql(normalized.showPropLabels), + boolToSql(normalized.showPropNames), + boolToSql(normalized.showTransitionMarkers), + boolToSql(normalized.showAllTransitionSets), + normalized.previousTransitionSetCount, + normalized.nextTransitionSetCount, + normalized.distanceGreenThresholdSteps, + normalized.distanceYellowThresholdSteps, + boolToSql(normalized.motionInterpolationEnabled), normalized.comfortableAnchorRangeMeters, normalized.activeDrillId, normalized.selectedDrillSetId, @@ -152,7 +232,9 @@ export function normalizeAppSettingsRow(row: unknown): AppSettings { function fromRow(row: AppSettingsRow): AppSettings { return normalizeAppSettings({ + appearanceMode: row.appearance_mode, drillFeaturesEnabled: sqliteBoolean(row.drill_features_enabled), + drillTerminology: row.drill_terminology, fieldPerspective: row.field_perspective, defaultFieldPreset: row.default_field_preset, transitionMetricMode: row.transition_metric_mode, @@ -163,6 +245,18 @@ function fromRow(row: AppSettingsRow): AppSettings { row.show_comfortable_anchor_range, ), showPerimeterStepGrid: sqliteBoolean(row.show_perimeter_step_grid), + showAuxiliaryFieldMarks: sqliteBoolean(row.show_auxiliary_field_marks), + showPerformerLabels: sqliteBoolean(row.show_performer_labels), + showPerformerNames: sqliteBoolean(row.show_performer_names), + showPropLabels: sqliteBoolean(row.show_prop_labels), + showPropNames: sqliteBoolean(row.show_prop_names), + showTransitionMarkers: sqliteBoolean(row.show_transition_markers), + showAllTransitionSets: sqliteBoolean(row.show_all_transition_sets), + previousTransitionSetCount: row.previous_transition_set_count, + nextTransitionSetCount: row.next_transition_set_count, + distanceGreenThresholdSteps: row.distance_green_threshold_steps, + distanceYellowThresholdSteps: row.distance_yellow_threshold_steps, + motionInterpolationEnabled: sqliteBoolean(row.motion_interpolation_enabled), comfortableAnchorRangeMeters: row.comfortable_anchor_range_meters, activeDrillId: row.active_drill_id, selectedDrillSetId: row.selected_drill_page_id, @@ -171,8 +265,9 @@ function fromRow(row: AppSettingsRow): AppSettings { function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { return ( + row.appearance_mode === settings.appearanceMode && row.drill_features_enabled === boolToSql(settings.drillFeaturesEnabled) && - row.drill_terminology === "sets" && + row.drill_terminology === settings.drillTerminology && row.field_perspective === settings.fieldPerspective && row.default_field_preset === settings.defaultFieldPreset && row.transition_metric_mode === settings.transitionMetricMode && @@ -184,6 +279,23 @@ function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { boolToSql(settings.showComfortableAnchorRange) && row.show_perimeter_step_grid === boolToSql(settings.showPerimeterStepGrid) && + row.show_auxiliary_field_marks === + boolToSql(settings.showAuxiliaryFieldMarks) && + row.show_performer_labels === boolToSql(settings.showPerformerLabels) && + row.show_performer_names === boolToSql(settings.showPerformerNames) && + row.show_prop_labels === boolToSql(settings.showPropLabels) && + row.show_prop_names === boolToSql(settings.showPropNames) && + row.show_transition_markers === boolToSql(settings.showTransitionMarkers) && + row.show_all_transition_sets === + boolToSql(settings.showAllTransitionSets) && + row.previous_transition_set_count === settings.previousTransitionSetCount && + row.next_transition_set_count === settings.nextTransitionSetCount && + row.distance_green_threshold_steps === + settings.distanceGreenThresholdSteps && + row.distance_yellow_threshold_steps === + settings.distanceYellowThresholdSteps && + row.motion_interpolation_enabled === + boolToSql(settings.motionInterpolationEnabled) && row.comfortable_anchor_range_meters === settings.comfortableAnchorRangeMeters && row.active_drill_id === settings.activeDrillId && diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts index 7ad486cd..1a9476ab 100644 --- a/packages/mobile/src/settings/__tests__/repository.test.ts +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -15,9 +15,10 @@ describe("app settings", () => { await expect(repository.load()).resolves.toEqual(DEFAULT_APP_SETTINGS); expect(fake.row).toMatchObject({ + appearance_mode: "system", drill_features_enabled: 1, drill_terminology: "sets", - field_perspective: "director", + field_perspective: "performer", default_field_preset: "football-nfhs", transition_metric_mode: "step-size", guidance_enabled: 1, @@ -25,16 +26,29 @@ describe("app settings", () => { show_cached_anchor_geometry: 0, show_comfortable_anchor_range: 0, show_perimeter_step_grid: 0, + show_auxiliary_field_marks: 1, + show_performer_labels: 1, + show_performer_names: 0, + show_prop_labels: 1, + show_prop_names: 0, + show_transition_markers: 1, + show_all_transition_sets: 0, + previous_transition_set_count: 1, + next_transition_set_count: 1, + distance_green_threshold_steps: 0.5, + distance_yellow_threshold_steps: 1, + motion_interpolation_enabled: 1, comfortable_anchor_range_meters: 20, active_drill_id: null, selected_drill_page_id: null, }); }); - test("normalizes invalid persisted values and pins terminology to sets", async () => { + test("normalizes invalid persisted values and rewrites a canonical row", async () => { const fake = new SettingsFakeDatabase({ + appearance_mode: "sepia", drill_features_enabled: 2, - drill_terminology: "pages", + drill_terminology: "legacy", field_perspective: "unknown", default_field_preset: "unknown", transition_metric_mode: "unknown", @@ -43,6 +57,18 @@ describe("app settings", () => { show_cached_anchor_geometry: 1, show_comfortable_anchor_range: 1, show_perimeter_step_grid: 1, + show_auxiliary_field_marks: "yes", + show_performer_labels: "yes", + show_performer_names: "yes", + show_prop_labels: "yes", + show_prop_names: "yes", + show_transition_markers: "yes", + show_all_transition_sets: "yes", + previous_transition_set_count: 1.5, + next_transition_set_count: Number.NaN, + distance_green_threshold_steps: Number.NaN, + distance_yellow_threshold_steps: 1, + motion_interpolation_enabled: "yes", comfortable_anchor_range_meters: Number.NaN, active_drill_id: 17, selected_drill_page_id: "", @@ -57,7 +83,15 @@ describe("app settings", () => { showComfortableAnchorRange: true, showPerimeterStepGrid: true, }); - expect(fake.row?.drill_terminology).toBe("sets"); + expect(fake.row).toMatchObject({ + appearance_mode: "system", + drill_terminology: "sets", + field_perspective: "performer", + previous_transition_set_count: 1, + next_transition_set_count: 1, + distance_green_threshold_steps: 0.5, + distance_yellow_threshold_steps: 1, + }); expect(fake.database.runAsync).toHaveBeenCalled(); expect(getEffectiveAppSettings(loaded)).toMatchObject({ developerModeEnabled: false, @@ -69,6 +103,7 @@ describe("app settings", () => { test("updates supplied fields while preserving drill/set selection", async () => { const fake = new SettingsFakeDatabase({ + appearance_mode: "system", drill_features_enabled: 1, drill_terminology: "sets", field_perspective: "director", @@ -79,6 +114,18 @@ describe("app settings", () => { show_cached_anchor_geometry: 1, show_comfortable_anchor_range: 1, show_perimeter_step_grid: 1, + show_auxiliary_field_marks: 0, + show_performer_labels: 0, + show_performer_names: 1, + show_prop_labels: 0, + show_prop_names: 1, + show_transition_markers: 0, + show_all_transition_sets: 1, + previous_transition_set_count: 2, + next_transition_set_count: 3, + distance_green_threshold_steps: 0.25, + distance_yellow_threshold_steps: 1.25, + motion_interpolation_enabled: 0, comfortable_anchor_range_meters: 30, active_drill_id: "drill-1", selected_drill_page_id: "set-1", @@ -104,10 +151,61 @@ describe("app settings", () => { expect(updated.showPerimeterStepGrid).toBe(true); }); + test("round trips every persisted preference field", async () => { + const fake = new SettingsFakeDatabase( + settingsRow({ + active_drill_id: "drill-1", + selected_drill_page_id: "set-1", + }), + ); + const repository = new SqliteSettingsRepository(fake.database); + + const updated = await repository.update({ + appearanceMode: "dark", + drillFeaturesEnabled: false, + drillTerminology: "pages", + fieldPerspective: "director", + defaultFieldPreset: "football-ncaa", + transitionMetricMode: "crossing-counts", + guidanceEnabled: false, + developerModeEnabled: true, + showCachedAnchorGeometry: true, + showComfortableAnchorRange: true, + showPerimeterStepGrid: true, + showAuxiliaryFieldMarks: false, + showPerformerLabels: false, + showPerformerNames: true, + showPropLabels: false, + showPropNames: true, + showTransitionMarkers: false, + showAllTransitionSets: true, + previousTransitionSetCount: 0, + nextTransitionSetCount: 50, + distanceGreenThresholdSteps: 0.75, + distanceYellowThresholdSteps: 1.5, + motionInterpolationEnabled: false, + comfortableAnchorRangeMeters: 30, + }); + + await expect(repository.load()).resolves.toEqual(updated); + expect(updated).toMatchObject({ + appearanceMode: "dark", + drillTerminology: "pages", + previousTransitionSetCount: 0, + nextTransitionSetCount: 50, + distanceGreenThresholdSteps: 0.75, + distanceYellowThresholdSteps: 1.5, + activeDrillId: "drill-1", + selectedDrillSetId: "set-1", + selectedDrillPageId: "set-1", + }); + }); + test("resetPreferences restores preference fields but preserves selection", async () => { const fake = new SettingsFakeDatabase({ + appearance_mode: "dark", drill_features_enabled: 0, - drill_terminology: "sets", + drill_terminology: "pages", field_perspective: "performer", default_field_preset: "football-nfl", transition_metric_mode: "crossing-counts", @@ -116,6 +214,18 @@ describe("app settings", () => { show_cached_anchor_geometry: 1, show_comfortable_anchor_range: 1, show_perimeter_step_grid: 1, + show_auxiliary_field_marks: 0, + show_performer_labels: 0, + show_performer_names: 1, + show_prop_labels: 0, + show_prop_names: 1, + show_transition_markers: 0, + show_all_transition_sets: 1, + previous_transition_set_count: 5, + next_transition_set_count: 6, + distance_green_threshold_steps: 0.75, + distance_yellow_threshold_steps: 1.5, + motion_interpolation_enabled: 0, comfortable_anchor_range_meters: 7, active_drill_id: "drill-1", selected_drill_page_id: "set-2", @@ -133,6 +243,61 @@ describe("app settings", () => { expect(fake.row?.drill_terminology).toBe("sets"); }); + test("normalizes appearance modes and accepts both terminology values", () => { + expect(normalizeAppSettings({ appearanceMode: "light" })).toMatchObject({ + appearanceMode: "light", + }); + expect(normalizeAppSettings({ appearanceMode: "unknown" })).toMatchObject({ + appearanceMode: "system", + }); + expect( + normalizeAppSettings({ drillTerminology: "sets" }).drillTerminology, + ).toBe("sets"); + expect( + normalizeAppSettings({ drillTerminology: "pages" }).drillTerminology, + ).toBe("pages"); + expect(normalizeAppSettings({}).fieldPerspective).toBe("performer"); + }); + + test("bounds transition counts and preserves threshold invariants", () => { + expect( + normalizeAppSettings({ + previousTransitionSetCount: -1, + nextTransitionSetCount: 51, + }), + ).toMatchObject({ + previousTransitionSetCount: 0, + nextTransitionSetCount: 50, + }); + expect( + normalizeAppSettings({ + previousTransitionSetCount: 1.5, + nextTransitionSetCount: Number.NaN, + }), + ).toMatchObject({ + previousTransitionSetCount: 1, + nextTransitionSetCount: 1, + }); + + const normalized = normalizeAppSettings({ + distanceGreenThresholdSteps: 2, + distanceYellowThresholdSteps: 1, + }); + expect(normalized.distanceGreenThresholdSteps).toBeLessThanOrEqual( + normalized.distanceYellowThresholdSteps, + ); + expect(normalized.distanceGreenThresholdSteps).toBeGreaterThanOrEqual(0); + expect( + normalizeAppSettings({ + distanceGreenThresholdSteps: Number.NaN, + distanceYellowThresholdSteps: Number.POSITIVE_INFINITY, + }), + ).toMatchObject({ + distanceGreenThresholdSteps: 0.5, + distanceYellowThresholdSteps: 1, + }); + }); + test("the pure normalizer treats malformed input as defaults", () => { expect( normalizeAppSettings({ @@ -194,40 +359,66 @@ class SettingsFakeDatabase { row: Record | null; constructor(row: Record | null) { - this.row = row; + this.row = row ? settingsRow(row) : null; this.database = { getFirstAsync: jest.fn(async () => (this.row ? { ...this.row } : null)), runAsync: jest.fn(async (sql: string, params: unknown[]) => { if (sql.includes("UPDATE app_settings")) { this.row = { ...(this.row ?? {}), - drill_features_enabled: params[0], - drill_terminology: "sets", - field_perspective: params[1], - default_field_preset: params[2], - transition_metric_mode: params[3], - guidance_enabled: params[4], - developer_mode_enabled: params[5], - show_cached_anchor_geometry: params[6], - show_comfortable_anchor_range: params[7], - show_perimeter_step_grid: params[8], - comfortable_anchor_range_meters: params[9], + appearance_mode: params[0], + drill_features_enabled: params[1], + drill_terminology: params[2], + field_perspective: params[3], + default_field_preset: params[4], + transition_metric_mode: params[5], + guidance_enabled: params[6], + developer_mode_enabled: params[7], + show_cached_anchor_geometry: params[8], + show_comfortable_anchor_range: params[9], + show_perimeter_step_grid: params[10], + show_auxiliary_field_marks: params[11], + show_performer_labels: params[12], + show_performer_names: params[13], + show_prop_labels: params[14], + show_prop_names: params[15], + show_transition_markers: params[16], + show_all_transition_sets: params[17], + previous_transition_set_count: params[18], + next_transition_set_count: params[19], + distance_green_threshold_steps: params[20], + distance_yellow_threshold_steps: params[21], + motion_interpolation_enabled: params[22], + comfortable_anchor_range_meters: params[23], }; } else { this.row = { - drill_features_enabled: params[1], - drill_terminology: "sets", - field_perspective: params[2], - default_field_preset: params[3], - transition_metric_mode: params[4], - guidance_enabled: params[5], - developer_mode_enabled: params[6], - show_cached_anchor_geometry: params[7], - show_comfortable_anchor_range: params[8], - show_perimeter_step_grid: params[9], - comfortable_anchor_range_meters: params[10], - active_drill_id: params[11], - selected_drill_page_id: params[12], + appearance_mode: params[1], + drill_features_enabled: params[2], + drill_terminology: params[3], + field_perspective: params[4], + default_field_preset: params[5], + transition_metric_mode: params[6], + guidance_enabled: params[7], + developer_mode_enabled: params[8], + show_cached_anchor_geometry: params[9], + show_comfortable_anchor_range: params[10], + show_perimeter_step_grid: params[11], + show_auxiliary_field_marks: params[12], + show_performer_labels: params[13], + show_performer_names: params[14], + show_prop_labels: params[15], + show_prop_names: params[16], + show_transition_markers: params[17], + show_all_transition_sets: params[18], + previous_transition_set_count: params[19], + next_transition_set_count: params[20], + distance_green_threshold_steps: params[21], + distance_yellow_threshold_steps: params[22], + motion_interpolation_enabled: params[23], + comfortable_anchor_range_meters: params[24], + active_drill_id: params[25], + selected_drill_page_id: params[26], }; } return { lastInsertRowId: 1, changes: 1 }; @@ -235,3 +426,35 @@ class SettingsFakeDatabase { } as unknown as SQLiteDatabase; } } + +function settingsRow(overrides: Record = {}) { + return { + appearance_mode: "system", + drill_features_enabled: 1, + drill_terminology: "sets", + field_perspective: "performer", + default_field_preset: "football-nfhs", + transition_metric_mode: "step-size", + guidance_enabled: 1, + developer_mode_enabled: 0, + show_cached_anchor_geometry: 0, + show_comfortable_anchor_range: 0, + show_perimeter_step_grid: 0, + show_auxiliary_field_marks: 1, + show_performer_labels: 1, + show_performer_names: 0, + show_prop_labels: 1, + show_prop_names: 0, + show_transition_markers: 1, + show_all_transition_sets: 0, + previous_transition_set_count: 1, + next_transition_set_count: 1, + distance_green_threshold_steps: 0.5, + distance_yellow_threshold_steps: 1, + motion_interpolation_enabled: 1, + comfortable_anchor_range_meters: 20, + active_drill_id: null, + selected_drill_page_id: null, + ...overrides, + }; +} diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts index b15dde62..93182324 100644 --- a/packages/mobile/src/settings/types.ts +++ b/packages/mobile/src/settings/types.ts @@ -1,16 +1,22 @@ import { isFieldPresetId, type FieldPresetId } from "@eight2five/drill-schema"; +import type { DrillTerminology } from "../drill/terminology"; export type FieldPerspective = "director" | "performer"; +export type AppearanceMode = "system" | "light" | "dark"; export type TransitionMetricMode = "step-size" | "crossing-counts"; export const DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS = 20; export const MAX_COMFORTABLE_ANCHOR_RANGE_METERS = 200; +export const MIN_TRANSITION_SET_COUNT = 0; +export const MAX_TRANSITION_SET_COUNT = 50; +export const DEFAULT_DISTANCE_GREEN_THRESHOLD_STEPS = 0.5; +export const DEFAULT_DISTANCE_YELLOW_THRESHOLD_STEPS = 1; /** App preferences plus persisted drill/set selection pointers. */ export interface AppSettings { + readonly appearanceMode: AppearanceMode; readonly drillFeaturesEnabled: boolean; - /** @deprecated Drill terminology is fixed to Sets. */ - readonly drillTerminology: "sets"; + readonly drillTerminology: DrillTerminology; readonly fieldPerspective: FieldPerspective; readonly defaultFieldPreset: FieldPresetId; readonly transitionMetricMode: TransitionMetricMode; @@ -19,6 +25,18 @@ export interface AppSettings { readonly showCachedAnchorGeometry: boolean; readonly showComfortableAnchorRange: boolean; readonly showPerimeterStepGrid: boolean; + readonly showAuxiliaryFieldMarks: boolean; + readonly showPerformerLabels: boolean; + readonly showPerformerNames: boolean; + readonly showPropLabels: boolean; + readonly showPropNames: boolean; + readonly showTransitionMarkers: boolean; + readonly showAllTransitionSets: boolean; + readonly previousTransitionSetCount: number; + readonly nextTransitionSetCount: number; + readonly distanceGreenThresholdSteps: number; + readonly distanceYellowThresholdSteps: number; + readonly motionInterpolationEnabled: boolean; readonly comfortableAnchorRangeMeters: number; readonly activeDrillId: string | null; readonly selectedDrillSetId: string | null; @@ -27,9 +45,10 @@ export interface AppSettings { } export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ + appearanceMode: "system", drillFeaturesEnabled: true, drillTerminology: "sets", - fieldPerspective: "director", + fieldPerspective: "performer", defaultFieldPreset: "football-nfhs", transitionMetricMode: "step-size", guidanceEnabled: true, @@ -37,6 +56,18 @@ export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ showCachedAnchorGeometry: false, showComfortableAnchorRange: false, showPerimeterStepGrid: false, + showAuxiliaryFieldMarks: true, + showPerformerLabels: true, + showPerformerNames: false, + showPropLabels: true, + showPropNames: false, + showTransitionMarkers: true, + showAllTransitionSets: false, + previousTransitionSetCount: 1, + nextTransitionSetCount: 1, + distanceGreenThresholdSteps: DEFAULT_DISTANCE_GREEN_THRESHOLD_STEPS, + distanceYellowThresholdSteps: DEFAULT_DISTANCE_YELLOW_THRESHOLD_STEPS, + motionInterpolationEnabled: true, comfortableAnchorRangeMeters: DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS, activeDrillId: null, selectedDrillSetId: null, @@ -44,7 +75,9 @@ export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ }); export const APP_PREFERENCE_KEYS = Object.freeze([ + "appearanceMode", "drillFeaturesEnabled", + "drillTerminology", "fieldPerspective", "defaultFieldPreset", "transitionMetricMode", @@ -53,6 +86,18 @@ export const APP_PREFERENCE_KEYS = Object.freeze([ "showCachedAnchorGeometry", "showComfortableAnchorRange", "showPerimeterStepGrid", + "showAuxiliaryFieldMarks", + "showPerformerLabels", + "showPerformerNames", + "showPropLabels", + "showPropNames", + "showTransitionMarkers", + "showAllTransitionSets", + "previousTransitionSetCount", + "nextTransitionSetCount", + "distanceGreenThresholdSteps", + "distanceYellowThresholdSteps", + "motionInterpolationEnabled", "comfortableAnchorRangeMeters", ] as const satisfies readonly (keyof AppSettings)[]); @@ -69,12 +114,26 @@ export interface AppSettingsRepository { export function normalizeAppSettings(value?: unknown): AppSettings { const candidate = isRecord(value) ? value : {}; const activeDrillId = nullableIdOrNull(candidate.activeDrillId); + const distanceThresholds = normalizeDistanceThresholds( + candidate.distanceGreenThresholdSteps, + candidate.distanceYellowThresholdSteps, + ); return { + appearanceMode: + candidate.appearanceMode === "system" || + candidate.appearanceMode === "light" || + candidate.appearanceMode === "dark" + ? candidate.appearanceMode + : DEFAULT_APP_SETTINGS.appearanceMode, drillFeaturesEnabled: booleanOrDefault( candidate.drillFeaturesEnabled, DEFAULT_APP_SETTINGS.drillFeaturesEnabled, ), - drillTerminology: "sets", + drillTerminology: + candidate.drillTerminology === "sets" || + candidate.drillTerminology === "pages" + ? candidate.drillTerminology + : DEFAULT_APP_SETTINGS.drillTerminology, fieldPerspective: candidate.fieldPerspective === "director" || candidate.fieldPerspective === "performer" @@ -108,6 +167,52 @@ export function normalizeAppSettings(value?: unknown): AppSettings { candidate.showPerimeterStepGrid, DEFAULT_APP_SETTINGS.showPerimeterStepGrid, ), + showAuxiliaryFieldMarks: booleanOrDefault( + candidate.showAuxiliaryFieldMarks, + DEFAULT_APP_SETTINGS.showAuxiliaryFieldMarks, + ), + showPerformerLabels: booleanOrDefault( + candidate.showPerformerLabels, + DEFAULT_APP_SETTINGS.showPerformerLabels, + ), + showPerformerNames: booleanOrDefault( + candidate.showPerformerNames, + DEFAULT_APP_SETTINGS.showPerformerNames, + ), + showPropLabels: booleanOrDefault( + candidate.showPropLabels, + DEFAULT_APP_SETTINGS.showPropLabels, + ), + showPropNames: booleanOrDefault( + candidate.showPropNames, + DEFAULT_APP_SETTINGS.showPropNames, + ), + showTransitionMarkers: booleanOrDefault( + candidate.showTransitionMarkers, + DEFAULT_APP_SETTINGS.showTransitionMarkers, + ), + showAllTransitionSets: booleanOrDefault( + candidate.showAllTransitionSets, + DEFAULT_APP_SETTINGS.showAllTransitionSets, + ), + previousTransitionSetCount: boundedIntegerOrDefault( + candidate.previousTransitionSetCount, + DEFAULT_APP_SETTINGS.previousTransitionSetCount, + MIN_TRANSITION_SET_COUNT, + MAX_TRANSITION_SET_COUNT, + ), + nextTransitionSetCount: boundedIntegerOrDefault( + candidate.nextTransitionSetCount, + DEFAULT_APP_SETTINGS.nextTransitionSetCount, + MIN_TRANSITION_SET_COUNT, + MAX_TRANSITION_SET_COUNT, + ), + distanceGreenThresholdSteps: distanceThresholds.green, + distanceYellowThresholdSteps: distanceThresholds.yellow, + motionInterpolationEnabled: booleanOrDefault( + candidate.motionInterpolationEnabled, + DEFAULT_APP_SETTINGS.motionInterpolationEnabled, + ), comfortableAnchorRangeMeters: positiveFiniteOrDefault( candidate.comfortableAnchorRangeMeters, DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, @@ -193,6 +298,36 @@ function positiveFiniteOrDefault(value: unknown, fallback: number): number { : fallback; } +function boundedIntegerOrDefault( + value: unknown, + fallback: number, + minimum: number, + maximum: number, +): number { + if (typeof value !== "number" || !Number.isInteger(value)) return fallback; + return Math.min(maximum, Math.max(minimum, value)); +} + +function normalizeDistanceThresholds( + greenValue: unknown, + yellowValue: unknown, +): { green: number; yellow: number } { + const green = nonNegativeFiniteOrDefault( + greenValue, + DEFAULT_APP_SETTINGS.distanceGreenThresholdSteps, + ); + const yellow = nonNegativeFiniteOrDefault( + yellowValue, + DEFAULT_APP_SETTINGS.distanceYellowThresholdSteps, + ); + return { green: Math.min(green, yellow), yellow }; +} + +function nonNegativeFiniteOrDefault(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.max(0, value); +} + function nullableIdOrNull(value: unknown): string | null { if (typeof value !== "string") return null; const normalized = value.trim(); diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index a6d2e0c6..015cf8a1 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -15,7 +15,7 @@ describe("mobile app SQLite schema preparation", () => { const sql = executed.join("\n"); expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); - expect(MOBILE_SCHEMA_VERSION).toBe(3); + expect(MOBILE_SCHEMA_VERSION).toBe(4); expect(sql).toContain("PRAGMA journal_mode = WAL"); expect(sql).toContain("PRAGMA foreign_keys = OFF"); expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); @@ -31,8 +31,28 @@ describe("mobile app SQLite schema preparation", () => { expect(sql).not.toContain("x_meters REAL"); expect(sql).not.toContain("y_meters REAL"); expect(sql).toContain("CREATE TABLE app_settings"); + expect(sql).toContain("appearance_mode TEXT NOT NULL DEFAULT 'system'"); + expect(sql).toContain("drill_terminology IN ('sets', 'pages')"); + expect(sql).toContain( + "field_perspective TEXT NOT NULL DEFAULT 'performer'", + ); expect(sql).toContain("default_field_preset TEXT NOT NULL"); expect(sql).toContain("show_perimeter_step_grid INTEGER NOT NULL"); + expect(sql).toContain( + "previous_transition_set_count INTEGER NOT NULL DEFAULT 1", + ); + expect(sql).toContain( + "next_transition_set_count INTEGER NOT NULL DEFAULT 1", + ); + expect(sql).toContain( + "distance_green_threshold_steps REAL NOT NULL DEFAULT 0.5", + ); + expect(sql).toContain( + "distance_yellow_threshold_steps REAL NOT NULL DEFAULT 1", + ); + expect(sql).toContain( + "motion_interpolation_enabled INTEGER NOT NULL DEFAULT 1", + ); expect(sql).toContain("REFERENCES drills(id) ON DELETE CASCADE"); expect(sql).toContain("REFERENCES drill_sets(id) ON DELETE SET NULL"); expect(sql).toContain(`PRAGMA user_version = ${MOBILE_SCHEMA_VERSION}`); diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index 78395c43..6e3e0251 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -10,7 +10,7 @@ export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; * stable, a version mismatch intentionally rebuilds this disposable database * rather than carrying migration code for development-only layouts. */ -export const MOBILE_SCHEMA_VERSION = 3; +export const MOBILE_SCHEMA_VERSION = 4; export const DRILLS_TABLE = "drills"; export const DRILL_SETS_TABLE = "drill_sets"; @@ -128,11 +128,13 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { CREATE TABLE ${APP_SETTINGS_TABLE} ( singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), + appearance_mode TEXT NOT NULL DEFAULT 'system' + CHECK (appearance_mode IN ('system', 'light', 'dark')), drill_features_enabled INTEGER NOT NULL DEFAULT 1 CHECK (drill_features_enabled IN (0, 1)), drill_terminology TEXT NOT NULL DEFAULT 'sets' - CHECK (drill_terminology = 'sets'), - field_perspective TEXT NOT NULL DEFAULT 'director' + CHECK (drill_terminology IN ('sets', 'pages')), + field_perspective TEXT NOT NULL DEFAULT 'performer' CHECK (field_perspective IN ('director', 'performer')), default_field_preset TEXT NOT NULL DEFAULT 'football-nfhs' CHECK (default_field_preset IN (${FIELD_PRESET_SQL_LIST})), @@ -148,6 +150,47 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { CHECK (show_comfortable_anchor_range IN (0, 1)), show_perimeter_step_grid INTEGER NOT NULL DEFAULT 0 CHECK (show_perimeter_step_grid IN (0, 1)), + show_auxiliary_field_marks INTEGER NOT NULL DEFAULT 1 + CHECK (show_auxiliary_field_marks IN (0, 1)), + show_performer_labels INTEGER NOT NULL DEFAULT 1 + CHECK (show_performer_labels IN (0, 1)), + show_performer_names INTEGER NOT NULL DEFAULT 0 + CHECK (show_performer_names IN (0, 1)), + show_prop_labels INTEGER NOT NULL DEFAULT 1 + CHECK (show_prop_labels IN (0, 1)), + show_prop_names INTEGER NOT NULL DEFAULT 0 + CHECK (show_prop_names IN (0, 1)), + show_transition_markers INTEGER NOT NULL DEFAULT 1 + CHECK (show_transition_markers IN (0, 1)), + show_all_transition_sets INTEGER NOT NULL DEFAULT 0 + CHECK (show_all_transition_sets IN (0, 1)), + previous_transition_set_count INTEGER NOT NULL DEFAULT 1 + CHECK ( + previous_transition_set_count >= 0 AND + previous_transition_set_count <= 50 AND + previous_transition_set_count = CAST(previous_transition_set_count AS INTEGER) + ), + next_transition_set_count INTEGER NOT NULL DEFAULT 1 + CHECK ( + next_transition_set_count >= 0 AND + next_transition_set_count <= 50 AND + next_transition_set_count = CAST(next_transition_set_count AS INTEGER) + ), + distance_green_threshold_steps REAL NOT NULL DEFAULT 0.5 + CHECK ( + distance_green_threshold_steps >= 0 AND + distance_green_threshold_steps = distance_green_threshold_steps + ), + distance_yellow_threshold_steps REAL NOT NULL DEFAULT 1 + CHECK ( + distance_yellow_threshold_steps >= 0 AND + distance_yellow_threshold_steps = distance_yellow_threshold_steps + ), + CHECK ( + distance_green_threshold_steps <= distance_yellow_threshold_steps + ), + motion_interpolation_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (motion_interpolation_enabled IN (0, 1)), comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 CHECK (comfortable_anchor_range_meters > 0), active_drill_id TEXT diff --git a/packages/ui/components/button/index.tsx b/packages/ui/components/button/index.tsx index 04f543f5..b2c7a41e 100644 --- a/packages/ui/components/button/index.tsx +++ b/packages/ui/components/button/index.tsx @@ -36,7 +36,7 @@ const buttonStyle = tva({ link: 'text-primary underline-offset-4 data-[hover=true]:underline', }, size: { - default: 'px-4 py-2', + default: 'min-h-10 px-4 py-2', sm: 'min-h-8 rounded-md px-3 text-xs', lg: 'min-h-10 rounded-md px-8', icon: 'min-h-9 min-w-9', @@ -44,7 +44,7 @@ const buttonStyle = tva({ }, }); const buttonTextStyle = tva({ - base: 'web:select-none font-heading-semibold', + base: 'web:select-none text-center font-heading-semibold', parentVariants: { variant: { default: 'text-primary-foreground', @@ -149,7 +149,10 @@ const ButtonText = React.forwardRef< (undefined); + +export function resolveEight2FiveThemeName( + mode: Eight2FiveThemeMode, + systemColorScheme: ColorSchemeName | null | undefined +): Eight2FiveThemeName { + if (mode !== 'system') return mode; + return systemColorScheme === 'dark' ? 'dark' : 'light'; +} + +export function Eight2FiveThemeProvider({ + mode, + children, +}: { + mode: Eight2FiveThemeMode; + children: React.ReactNode; +}) { + const systemColorScheme = useColorScheme(); + const themeName = resolveEight2FiveThemeName(mode, systemColorScheme); + + return ( + + {children} + + ); +} + +export function useEight2FiveThemeName(): Eight2FiveThemeName { + const providedThemeName = React.useContext(Eight2FiveThemeNameContext); + const systemColorScheme = useColorScheme(); + return ( + providedThemeName ?? + resolveEight2FiveThemeName('system', systemColorScheme) + ); +} export function useEight2FiveTheme(): Eight2FiveTheme { - return eight2FiveThemes[useColorScheme() === 'dark' ? 'dark' : 'light']; + return eight2FiveThemes[useEight2FiveThemeName()]; } export function useEight2FiveFonts(): [boolean, Error | null] { From 08646fbf684732927a72a4500d2a9fe73b5c8259 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 03:34:04 -0500 Subject: [PATCH 058/101] fix(settings): Apply terminology preference Use the persisted Sets or Pages choice across current drill and field UI, and expose stable labels and test targets around native settings pickers. --- .../drill/components/drill-page-list-item.tsx | 9 ++++----- .../components/marching-coordinate-form.tsx | 20 ++++++++++--------- .../src/features/drill/page-editor-screen.tsx | 9 ++++++--- .../drill/use-drill-editor-controller.ts | 2 +- .../drill/use-drill-list-controller.ts | 2 +- .../drill/use-page-editor-controller.ts | 2 +- .../__tests__/coordinate-panel-state.test.ts | 15 +++++++++++++- .../coordinate-panel-state.ts | 20 ++++++++++++------- .../coordinate-panel/drill-coordinate-row.tsx | 8 ++++---- .../src/features/field/field-screen.tsx | 4 ++-- .../features/settings/settings-components.tsx | 4 +++- 11 files changed, 60 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/drill/components/drill-page-list-item.tsx b/apps/mobile/src/features/drill/components/drill-page-list-item.tsx index e7b042c3..59cd224f 100644 --- a/apps/mobile/src/features/drill/components/drill-page-list-item.tsx +++ b/apps/mobile/src/features/drill/components/drill-page-list-item.tsx @@ -34,7 +34,7 @@ import { TransitionSummary } from "./transition-summary"; export const DrillPageListItem = React.memo(function DrillPageListItem({ page, previousPage, - terms: _terms, + terms, fieldPreset, selected, busy, @@ -47,8 +47,7 @@ export const DrillPageListItem = React.memo(function DrillPageListItem({ }: { page: DrillSet; previousPage?: DrillSet; - /** @deprecated Sets are now the only user-facing terminology. */ - terms?: DrillTerms; + terms: DrillTerms; fieldPreset: FieldPresetId; selected: boolean; busy: boolean; @@ -67,7 +66,7 @@ export const DrillPageListItem = React.memo(function DrillPageListItem({ const side = formatMarchingSide(coordinate.side); const frontBack = formatMarchingFrontBack(coordinate.frontBack, fieldPreset); const setName = formatSetName(page); - const title = `Set ${setName}`; + const title = `${terms.singular} ${setName}`; const measures = page.measureRange ? page.measureRange.start === page.measureRange.end ? `Measure ${page.measureRange.start}` @@ -90,7 +89,7 @@ export const DrillPageListItem = React.memo(function DrillPageListItem({ disabled={busy} accessibilityRole="button" accessibilityLabel={`${title}. ${page.countsFromPrevious} counts.${measures ? ` ${measures}.` : ""} ${side}. ${frontBack}.${selected ? " Selected." : ""}`} - accessibilityHint="Edits this set" + accessibilityHint={`Edits this ${terms.lowercaseSingular}`} accessibilityState={{ selected, disabled: busy }} > ( key: Key, value: MarchingCoordinateDraft[Key], @@ -131,20 +133,20 @@ export function MarchingCoordinateForm({ return ( {showDetails ? ( - + update("setNumber", value)} /> onChange({ diff --git a/apps/mobile/src/features/drill/page-editor-screen.tsx b/apps/mobile/src/features/drill/page-editor-screen.tsx index c02ff87b..394565b7 100644 --- a/apps/mobile/src/features/drill/page-editor-screen.tsx +++ b/apps/mobile/src/features/drill/page-editor-screen.tsx @@ -36,7 +36,7 @@ export function PageEditorScreen({ placement, relativePageId, ); - const title = `${pageId === "new" ? "Add" : "Edit"} Set`; + const title = `${pageId === "new" ? "Add" : "Edit"} ${controller.terms.singular}`; return ( @@ -52,7 +52,9 @@ export function PageEditorScreen({ }} > {controller.loading ? ( - Loading set… + + Loading {controller.terms.lowercaseSingular}… + ) : null} {controller.error ? ( @@ -63,6 +65,7 @@ export function PageEditorScreen({ @@ -92,7 +95,7 @@ export function PageEditorScreen({ }} > {controller.saving ? : } - Save Set + Save {controller.terms.singular} diff --git a/apps/mobile/src/features/drill/use-drill-editor-controller.ts b/apps/mobile/src/features/drill/use-drill-editor-controller.ts index d8ee6c39..207b7c61 100644 --- a/apps/mobile/src/features/drill/use-drill-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-editor-controller.ts @@ -211,7 +211,7 @@ export function useDrillEditorController(drillId: string) { busyPageId, active: snapshot.settings.activeDrillId === drillId, selectedPageId: snapshot.settings.selectedDrillSetId, - terms: getDrillTerms("sets"), + terms: getDrillTerms(snapshot.settings.drillTerminology), error: error ?? snapshot.error, refresh, saveName, diff --git a/apps/mobile/src/features/drill/use-drill-list-controller.ts b/apps/mobile/src/features/drill/use-drill-list-controller.ts index a7a4d0fe..e019a32a 100644 --- a/apps/mobile/src/features/drill/use-drill-list-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-list-controller.ts @@ -118,7 +118,7 @@ export function useDrillListController() { error: error ?? snapshot.error, busyDrillId, activeDrillId: snapshot.settings.activeDrillId, - terms: getDrillTerms("sets"), + terms: getDrillTerms(snapshot.settings.drillTerminology), refresh, rename, makeActive, diff --git a/apps/mobile/src/features/drill/use-page-editor-controller.ts b/apps/mobile/src/features/drill/use-page-editor-controller.ts index 64ad8dfa..1c18054b 100644 --- a/apps/mobile/src/features/drill/use-page-editor-controller.ts +++ b/apps/mobile/src/features/drill/use-page-editor-controller.ts @@ -135,7 +135,7 @@ export function usePageEditorController( fieldPreset, loading: snapshot.status === "loading" || loading, saving, - terms: getDrillTerms("sets"), + terms: getDrillTerms(snapshot.settings.drillTerminology), error: error ?? snapshot.error, save, } as const; diff --git a/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts b/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts index be78eaf0..b4deed79 100644 --- a/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts +++ b/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts @@ -57,10 +57,11 @@ describe("coordinate panel state", () => { ).toMatchObject({ statusLabel: "Last known position", muted: true }); }); - test("uses fixed Set terminology and separate count/measure fields", () => { + test("uses the selected terminology and separate count/measure fields", () => { expect( getDrillCoordinatePresentation({ metricMode: "step-size", + terminology: "sets", }), ).toEqual({ term: "Set", @@ -72,6 +73,15 @@ describe("coordinate panel state", () => { coordinate: null, emptyMessage: "No drill set selected", }); + expect( + getDrillCoordinatePresentation({ + metricMode: "step-size", + terminology: "pages", + }), + ).toMatchObject({ + term: "Page", + emptyMessage: "No drill page selected", + }); }); test("toggles between step-size and crossing-count metrics", () => { @@ -79,11 +89,13 @@ describe("coordinate panel state", () => { page: second, previousPage: first, metricMode: "step-size", + terminology: "sets", }); const crossingCounts = getDrillCoordinatePresentation({ page: second, previousPage: first, metricMode: "crossing-counts", + terminology: "sets", }); expect(stepSize).toMatchObject({ @@ -102,6 +114,7 @@ describe("coordinate panel state", () => { getDrillCoordinatePresentation({ page: first, metricMode: "step-size", + terminology: "sets", }).counts, ).toBe("0"); }); diff --git a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts index cd56cbd5..39a4db85 100644 --- a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts +++ b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts @@ -5,7 +5,12 @@ import { formatMarchingSide, type FieldLivePositionState, } from "@eight2five/mobile/field"; -import { formatSetName, type DrillSet } from "@eight2five/mobile/drill"; +import { + formatSetName, + getDrillTerms, + type DrillSet, + type DrillTerminology, +} from "@eight2five/mobile/drill"; import type { TransitionMetricMode } from "@eight2five/mobile/settings"; import type { FieldPresetId } from "@eight2five/drill-schema"; @@ -24,7 +29,7 @@ export interface LiveCoordinatePresentation { } export interface DrillCoordinatePresentation { - readonly term: "Set"; + readonly term: "Page" | "Set"; readonly set: string; readonly counts: string; readonly measures: string; @@ -85,30 +90,31 @@ export function getDrillCoordinatePresentation({ previousPage, metricMode, fieldPreset = "football-nfhs", + terminology, }: { readonly page?: DrillSet; readonly previousPage?: DrillSet; readonly metricMode: TransitionMetricMode; readonly fieldPreset?: FieldPresetId; - /** @deprecated Sets are the only supported terminology. */ - readonly terminology?: unknown; + readonly terminology: DrillTerminology; }): DrillCoordinatePresentation { + const terms = getDrillTerms(terminology); const metricLabel = metricMode === "step-size" ? "Step Size" : "xCounts"; if (!page) { return { - term: "Set", + term: terms.singular, set: "–", counts: "–", measures: "–", metricLabel, metric: "–", coordinate: null, - emptyMessage: "No drill set selected", + emptyMessage: `No drill ${terms.lowercaseSingular} selected`, }; } const transition = getTransitionPresentation(previousPage, page); return { - term: "Set", + term: terms.singular, set: formatSetName(page), counts: String(page.countsFromPrevious), measures: page.measureRange diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx index d0692c16..795e69aa 100644 --- a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx +++ b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx @@ -59,7 +59,7 @@ function DrillCoordinate({ export function DrillCoordinateRow({ page, previousPage, - terminology: _terminology, + terminology, metricMode, fieldPreset, landscape, @@ -68,8 +68,7 @@ export function DrillCoordinateRow({ }: { readonly page?: DrillSet; readonly previousPage?: DrillSet; - /** @deprecated Sets are the only terminology; kept for call-site compatibility. */ - readonly terminology?: DrillTerminology; + readonly terminology: DrillTerminology; readonly metricMode: TransitionMetricMode; readonly fieldPreset: FieldPresetId; readonly landscape: boolean; @@ -81,10 +80,11 @@ export function DrillCoordinateRow({ previousPage, metricMode, fieldPreset, + terminology, }); const metadata = ( - + diff --git a/apps/mobile/src/features/settings/settings-components.tsx b/apps/mobile/src/features/settings/settings-components.tsx index 5aeb0140..f89bb2f4 100644 --- a/apps/mobile/src/features/settings/settings-components.tsx +++ b/apps/mobile/src/features/settings/settings-components.tsx @@ -227,6 +227,8 @@ export function SettingsSelectRow({ ({ onValueChange={onChange} enabled={!disabled} appearance="menu" - testID={testID} + testID={testID ? `${testID}-picker` : undefined} > {choices.map((choice) => ( Date: Wed, 5 Aug 2026 03:41:45 -0500 Subject: [PATCH 059/101] fix(settings): Remove fixed set terminology Apply the selected drill noun to actions and form copy, and use neutral wording where lower-level errors do not have settings context. --- .../drill/components/drill-page-actions.tsx | 24 +++++++++++-------- .../components/marching-coordinate-form.tsx | 2 +- .../mobile/src/features/drill/drill-import.ts | 4 ++-- .../src/features/drill/page-management.ts | 4 ++-- .../src/features/settings/settings-screen.tsx | 2 +- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/drill/components/drill-page-actions.tsx b/apps/mobile/src/features/drill/components/drill-page-actions.tsx index 9e0af81a..9a551819 100644 --- a/apps/mobile/src/features/drill/components/drill-page-actions.tsx +++ b/apps/mobile/src/features/drill/components/drill-page-actions.tsx @@ -19,12 +19,12 @@ import { useEight2FiveTheme } from "@eight2five/ui/theme"; export function confirmDeletePage( page: DrillSet, - _terms: DrillTerms, + terms: DrillTerms, onConfirm: () => void, ) { Alert.alert( - `Delete Set ${formatSetName(page)}?`, - "This permanently deletes the set.", + `Delete ${terms.singular} ${formatSetName(page)}?`, + `This permanently deletes the ${terms.lowercaseSingular}.`, [ { text: "Cancel", style: "cancel" }, { text: "Delete", style: "destructive", onPress: onConfirm }, @@ -34,7 +34,7 @@ export function confirmDeletePage( export function DrillPageActionsSheet({ page, - terms: _terms, + terms, drillActive, selected, onClose, @@ -45,7 +45,7 @@ export function DrillPageActionsSheet({ onDelete, }: { page?: DrillSet; - terms?: DrillTerms; + terms: DrillTerms; drillActive: boolean; selected: boolean; onClose(): void; @@ -66,25 +66,29 @@ export function DrillPageActionsSheet({ {drillActive && !selected ? ( - Select Set + Select {terms.singular} ) : null} - Edit Set + Edit {terms.singular} - Insert set before + + Insert {terms.lowercaseSingular} before + - Insert set after + + Insert {terms.lowercaseSingular} after + - Delete Set + Delete {terms.singular} diff --git a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx index a9d63f48..5decc34f 100644 --- a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx +++ b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx @@ -172,7 +172,7 @@ export function MarchingCoordinateForm({ error={validation.errors.countsFromPrevious} disabled={disabled} numeric - helper="Whole-number transition counts. The first set is always 0." + helper={`Whole-number transition counts. The first ${terms.lowercaseSingular} is always 0.`} onChangeText={(value) => update("countsFromPrevious", value)} /> !positionedSetIds.has(set.id))) { throw new Error( - `Every set must include a position for ${performer.label}.`, + `Every drill position must include a coordinate for ${performer.label}.`, ); } diff --git a/apps/mobile/src/features/drill/page-management.ts b/apps/mobile/src/features/drill/page-management.ts index befe4b85..df42c9cd 100644 --- a/apps/mobile/src/features/drill/page-management.ts +++ b/apps/mobile/src/features/drill/page-management.ts @@ -49,7 +49,7 @@ export async function savePageDraft({ const validation = validatePageDraft(draft, fieldPreset); if (!validation.value) { const message = - Object.values(validation.errors)[0] ?? "Review the set form."; + Object.values(validation.errors)[0] ?? "Review the drill position form."; throw new Error(message); } const details = { @@ -81,7 +81,7 @@ export function reorderedPageIds( direction: SetMoveDirection, ): readonly string[] | undefined { const index = sets.findIndex((set) => set.id === setId); - if (index < 0) throw new Error("The set to move no longer exists."); + if (index < 0) throw new Error("The position to move no longer exists."); const destination = direction === "up" ? index - 1 : index + 1; if (destination < 0 || destination >= sets.length) return undefined; const ids = sets.map((set) => set.id); diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 62ed9d9d..71c2e55f 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -151,7 +151,7 @@ export function SettingsScreen() { icon={BookOpenText} title="Drill terminology" - description="Choose whether drill positions are called Sets or Pages." + description="Choose the name used for drill positions." value={settings.drillTerminology} choices={TERMINOLOGY_CHOICES} onChange={(drillTerminology) => void update({ drillTerminology })} From 1e5d6d93a7d31857faf133ff2909cf49dcca1cca Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 03:50:37 -0500 Subject: [PATCH 060/101] fix(settings): Label native picker targets Expose each Expo UI picker host as a labeled combobox with its human-readable value, and remove remaining fixed Set wording from active settings and validation copy. --- apps/mobile/src/features/drill/__tests__/page-form.test.ts | 4 ++-- apps/mobile/src/features/drill/page-form.ts | 4 ++-- apps/mobile/src/features/settings/settings-components.tsx | 7 +++++++ apps/mobile/src/features/settings/settings-screen.tsx | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/drill/__tests__/page-form.test.ts b/apps/mobile/src/features/drill/__tests__/page-form.test.ts index d0993db5..1e1c0149 100644 --- a/apps/mobile/src/features/drill/__tests__/page-form.test.ts +++ b/apps/mobile/src/features/drill/__tests__/page-form.test.ts @@ -169,7 +169,7 @@ describe("structured marching coordinate form", () => { expect(result.value?.coordinate.frontBack.relation).toBe("on"); }); - test("returns actionable set, count, measure, relation, and bounds errors", () => { + test("returns actionable position, count, measure, relation, and bounds errors", () => { expect( validatePageDraft({ ...VALID_DRAFT, @@ -180,7 +180,7 @@ describe("structured marching coordinate form", () => { measureEnd: "129", }).errors, ).toMatchObject({ - setNumber: expect.stringContaining("set number"), + setNumber: expect.stringContaining("position number"), setSuffix: expect.stringContaining("capital letter"), countsFromPrevious: expect.stringContaining("whole-number"), measureEnd: expect.stringContaining("after"), diff --git a/apps/mobile/src/features/drill/page-form.ts b/apps/mobile/src/features/drill/page-form.ts index 7ef23be2..90fa46b8 100644 --- a/apps/mobile/src/features/drill/page-form.ts +++ b/apps/mobile/src/features/drill/page-form.ts @@ -178,13 +178,13 @@ export function validatePageDraft( const errors: SetFormErrors = {}; const setNumber = parseNonNegativeInteger( draft.setNumber, - "Enter a non-negative set number.", + "Enter a non-negative drill position number.", ); if (typeof setNumber === "string") errors.setNumber = setNumber; const suffix = draft.setSuffix.trim(); if (draft.setKind === "set" && suffix) { - errors.setSuffix = "Primary sets do not have a suffix."; + errors.setSuffix = "Primary drill positions do not have a suffix."; } else if ( draft.setKind === "subset" && !/^(?:[A-Z]|\.[0-9]+)$/.test(suffix) diff --git a/apps/mobile/src/features/settings/settings-components.tsx b/apps/mobile/src/features/settings/settings-components.tsx index f89bb2f4..c9a58ab9 100644 --- a/apps/mobile/src/features/settings/settings-components.tsx +++ b/apps/mobile/src/features/settings/settings-components.tsx @@ -223,12 +223,19 @@ export function SettingsSelectRow({ }) { const theme = useEight2FiveTheme(); const themeName = useEight2FiveThemeName(); + const selectedLabel = + choices.find((choice) => choice.value === value)?.label ?? value; return ( void setDrillFeatures(enabled)} disabled={disabled} From fd11326c3adafaab982d650a703da51191995d6b Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 04:01:24 -0500 Subject: [PATCH 061/101] feat(drill-schema): Model preset field markings Move football number, hash, and sideline-mark dimensions into resolved field definitions, require custom fields to describe their markings, and share canonical drill colors with the UI theme. --- .../drill-converter/src/converter/settings.ts | 1 + .../drill/__tests__/drill-import.test.ts | 2 + .../state/__tests__/appearance-theme.test.ts | 8 +++ package-lock.json | 1 + .../drill-schema/drill-document.schema.json | 47 +++++++++++++++- .../drill-schema/src/__tests__/schema.test.ts | 54 +++++++++++++++++++ packages/drill-schema/src/field-presets.ts | 48 +++++++++++++++++ packages/drill-schema/src/field-projection.ts | 1 + packages/drill-schema/src/schema.ts | 28 ++++++++++ packages/drill-schema/src/types.ts | 22 ++++++++ packages/ui/package.json | 3 +- packages/ui/theme/index.tsx | 5 +- packages/ui/theme/theme.css | 21 +++++--- 13 files changed, 230 insertions(+), 11 deletions(-) diff --git a/apps/drill-converter/src/converter/settings.ts b/apps/drill-converter/src/converter/settings.ts index 77b03dd6..3be907ee 100644 --- a/apps/drill-converter/src/converter/settings.ts +++ b/apps/drill-converter/src/converter/settings.ts @@ -124,6 +124,7 @@ export function createDefaultCustomFieldJson(): string { name: "Custom Football Field", physicalGeometry: preset.physicalGeometry, marchingGrid: preset.marchingGrid, + markings: preset.markings, } satisfies FieldDefinition, null, 2, diff --git a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts index 156a35fb..c650f03a 100644 --- a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts +++ b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts @@ -2,6 +2,7 @@ import type { DrillRepository } from "@eight2five/mobile/drill"; import { DRILL_SCHEMA_URL, DRILL_SCHEMA_VERSION, + getFieldPreset, type DrillDocument, } from "@eight2five/drill-schema"; @@ -249,6 +250,7 @@ describe("Eight2Five drill import", () => { { id: "back", name: "Back", axis: "y", coordinateSteps: 10 }, ], }, + markings: getFieldPreset("football-nfhs").markings, }, } satisfies DrillDocument; expect(() => diff --git a/apps/mobile/src/state/__tests__/appearance-theme.test.ts b/apps/mobile/src/state/__tests__/appearance-theme.test.ts index 057489a5..d636ee75 100644 --- a/apps/mobile/src/state/__tests__/appearance-theme.test.ts +++ b/apps/mobile/src/state/__tests__/appearance-theme.test.ts @@ -1,7 +1,9 @@ import { + eight2FiveDrillColors, eight2FiveThemes, resolveEight2FiveThemeName, } from "@eight2five/ui/theme"; +import { COLOR_PRESETS } from "@eight2five/drill-schema"; describe("app appearance", () => { test("uses the OS appearance only in system mode", () => { @@ -18,4 +20,10 @@ describe("app appearance", () => { eight2FiveThemes.dark.background, ); }); + + test("shares the drill color presets with the UI theme", () => { + expect(eight2FiveDrillColors).toBe(COLOR_PRESETS); + expect(eight2FiveThemes.light.accent).toBe(COLOR_PRESETS.blue); + expect(eight2FiveThemes.dark.accent).toBe(COLOR_PRESETS.blue); + }); }); diff --git a/package-lock.json b/package-lock.json index fab55d88..18eb3e13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19253,6 +19253,7 @@ "name": "@eight2five/ui", "version": "0.0.0", "dependencies": { + "@eight2five/drill-schema": "0.0.0", "@expo-google-fonts/montserrat": "^0.4.2", "@expo-google-fonts/source-sans-3": "^0.4.1", "@expo/html-elements": "^0.12.5", diff --git a/packages/drill-schema/drill-document.schema.json b/packages/drill-schema/drill-document.schema.json index b5fa91a2..b6f5ae6c 100644 --- a/packages/drill-schema/drill-document.schema.json +++ b/packages/drill-schema/drill-document.schema.json @@ -586,6 +586,48 @@ } } }, + "fieldMarkings": { + "type": "object", + "additionalProperties": false, + "required": ["yardNumbers", "inboundsHashMarks", "sidelineHashMarks"], + "properties": { + "yardNumbers": { + "type": "object", + "additionalProperties": false, + "required": [ + "heightMeters", + "nominalWidthMeters", + "centerFromFrontSidelineMeters", + "centerFromBackSidelineMeters" + ], + "properties": { + "heightMeters": { "type": "number", "exclusiveMinimum": 0 }, + "nominalWidthMeters": { "type": "number", "exclusiveMinimum": 0 }, + "centerFromFrontSidelineMeters": { "type": "number", "minimum": 0 }, + "centerFromBackSidelineMeters": { "type": "number", "minimum": 0 } + } + }, + "inboundsHashMarks": { + "type": "object", + "additionalProperties": false, + "required": ["lengthMeters", "spacingMeters"], + "properties": { + "lengthMeters": { "type": "number", "exclusiveMinimum": 0 }, + "spacingMeters": { "type": "number", "exclusiveMinimum": 0 } + } + }, + "sidelineHashMarks": { + "type": "object", + "additionalProperties": false, + "required": ["lengthMeters", "spacingMeters", "insetFromSidelineMeters"], + "properties": { + "lengthMeters": { "type": "number", "exclusiveMinimum": 0 }, + "spacingMeters": { "type": "number", "exclusiveMinimum": 0 }, + "insetFromSidelineMeters": { "type": "number", "minimum": 0 } + } + } + } + }, "fieldDefinition": { "oneOf": [ { @@ -609,7 +651,7 @@ { "type": "object", "additionalProperties": false, - "required": ["type", "name", "physicalGeometry", "marchingGrid"], + "required": ["type", "name", "physicalGeometry", "marchingGrid", "markings"], "properties": { "type": { "const": "custom" @@ -651,6 +693,9 @@ } } } + }, + "markings": { + "$ref": "#/$defs/fieldMarkings" } } } diff --git a/packages/drill-schema/src/__tests__/schema.test.ts b/packages/drill-schema/src/__tests__/schema.test.ts index 08c4e327..e6de8f13 100644 --- a/packages/drill-schema/src/__tests__/schema.test.ts +++ b/packages/drill-schema/src/__tests__/schema.test.ts @@ -293,6 +293,60 @@ describe("drill schema", () => { expect(isFieldPresetId("football-made-up")).toBe(false); }); + it.each([ + ["football-nfhs", 53 + 4 / 12, 24, 4], + ["football-ncaa", 60, 24, 4], + ["football-texas-uil", 60, 24, 4], + ["football-nfl", 70 + 9 / 12, 39, 8], + ] as const)( + "%s exposes its physical football markings", + (presetId, hashFeet, numberCenterFeet, sidelineInsetInches) => { + const field = getFieldPreset(presetId); + const frontHash = field.physicalGeometry.referenceLines.find( + (line) => line.id === "front-hash", + ); + expect(frontHash?.coordinateMeters).toBeCloseTo(hashFeet * 0.3048, 8); + expect(field.markings).toMatchObject({ + yardNumbers: { + heightMeters: 6 * 0.3048, + nominalWidthMeters: 4 * 0.3048, + centerFromFrontSidelineMeters: numberCenterFeet * 0.3048, + centerFromBackSidelineMeters: numberCenterFeet * 0.3048, + }, + inboundsHashMarks: { + lengthMeters: 2 * 0.3048, + spacingMeters: 0.9144, + }, + sidelineHashMarks: { + lengthMeters: 2 * 0.3048, + spacingMeters: 0.9144, + insetFromSidelineMeters: (sidelineInsetInches / 12) * 0.3048, + }, + }); + expect(field.markings.yardNumbers.centerFromFrontSidelineMeters).toBe( + field.markings.yardNumbers.centerFromBackSidelineMeters, + ); + }, + ); + + it("parses self-describing custom field markings", () => { + const preset = getFieldPreset("football-nfhs"); + const parsed = parseDrillDocument({ + ...fixture, + field: { + type: "custom", + name: "Custom Football Field", + physicalGeometry: preset.physicalGeometry, + marchingGrid: preset.marchingGrid, + markings: preset.markings, + }, + }); + expect(parsed.field).toMatchObject({ + type: "custom", + markings: preset.markings, + }); + }); + it.each(FIELD_PRESET_IDS)( "%s uses the canonical 160 by 84 marching-grid bounds", (preset) => { diff --git a/packages/drill-schema/src/field-presets.ts b/packages/drill-schema/src/field-presets.ts index e58051bc..35f35b31 100644 --- a/packages/drill-schema/src/field-presets.ts +++ b/packages/drill-schema/src/field-presets.ts @@ -1,6 +1,7 @@ import { FIELD_PRESET_IDS, type FieldPresetId, + type FieldMarkingDefinition, type MarchingReferenceLine, type PhysicalReferenceLine, type ResolvedFieldDefinition, @@ -11,6 +12,32 @@ const YARDS_TO_METERS = 0.9144; const FIELD_HALF_LENGTH_METERS = 50 * YARDS_TO_METERS; const FIELD_WIDTH_METERS = 160 * FEET_TO_METERS; +function footballMarkings({ + yardNumberCenterFeet, + sidelineInsetInches, +}: { + yardNumberCenterFeet: number; + sidelineInsetInches: number; +}): FieldMarkingDefinition { + return Object.freeze({ + yardNumbers: Object.freeze({ + heightMeters: 6 * FEET_TO_METERS, + nominalWidthMeters: 4 * FEET_TO_METERS, + centerFromFrontSidelineMeters: yardNumberCenterFeet * FEET_TO_METERS, + centerFromBackSidelineMeters: yardNumberCenterFeet * FEET_TO_METERS, + }), + inboundsHashMarks: Object.freeze({ + lengthMeters: 2 * FEET_TO_METERS, + spacingMeters: YARDS_TO_METERS, + }), + sidelineHashMarks: Object.freeze({ + lengthMeters: 2 * FEET_TO_METERS, + spacingMeters: YARDS_TO_METERS, + insetFromSidelineMeters: (sidelineInsetInches / 12) * FEET_TO_METERS, + }), + }); +} + function xReferenceLines(): { physical: readonly PhysicalReferenceLine[]; marching: readonly MarchingReferenceLine[]; @@ -50,12 +77,14 @@ function makeFootballPreset({ physicalFrontHashFeet, gridFrontHashSteps, gridBackHashSteps, + markings, }: { id: FieldPresetId; name: string; physicalFrontHashFeet: number; gridFrontHashSteps: number; gridBackHashSteps: number; + markings: FieldMarkingDefinition; }): ResolvedFieldDefinition { const physicalFrontHashMeters = physicalFrontHashFeet * FEET_TO_METERS; const physicalBackHashMeters = FIELD_WIDTH_METERS - physicalFrontHashMeters; @@ -139,6 +168,7 @@ function makeFootballPreset({ ...marchingYReferences, ]), }), + markings, }); } @@ -164,6 +194,12 @@ export const FIELD_PRESETS: Readonly { diff --git a/packages/drill-schema/src/types.ts b/packages/drill-schema/src/types.ts index 55ccf362..2b9c7832 100644 --- a/packages/drill-schema/src/types.ts +++ b/packages/drill-schema/src/types.ts @@ -173,6 +173,26 @@ export interface MarchingGrid { readonly referenceLines: readonly MarchingReferenceLine[]; } +/** Physical football-marking dimensions consumed by field renderers. */ +export interface FieldMarkingDefinition { + readonly yardNumbers: { + readonly heightMeters: number; + readonly nominalWidthMeters: number; + readonly centerFromFrontSidelineMeters: number; + readonly centerFromBackSidelineMeters: number; + }; + readonly inboundsHashMarks: { + readonly lengthMeters: number; + readonly spacingMeters: number; + }; + readonly sidelineHashMarks: { + readonly lengthMeters: number; + readonly spacingMeters: number; + /** Clear distance from the inside edge of the sideline to each mark. */ + readonly insetFromSidelineMeters: number; + }; +} + export interface PresetFieldDefinition { readonly type: "preset"; readonly preset: FieldPresetId; @@ -183,6 +203,7 @@ export interface CustomFieldDefinition { readonly name: string; readonly physicalGeometry: PhysicalFieldGeometry; readonly marchingGrid: MarchingGrid; + readonly markings: FieldMarkingDefinition; } export type FieldDefinition = PresetFieldDefinition | CustomFieldDefinition; @@ -192,6 +213,7 @@ export interface ResolvedFieldDefinition { readonly name: string; readonly physicalGeometry: PhysicalFieldGeometry; readonly marchingGrid: MarchingGrid; + readonly markings: FieldMarkingDefinition; } export type SourceReferenceTarget = diff --git a/packages/ui/package.json b/packages/ui/package.json index 15e713b9..cb2698cb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -72,6 +72,7 @@ "./theme": "./theme/index.tsx" }, "dependencies": { + "@eight2five/drill-schema": "0.0.0", "@expo-google-fonts/montserrat": "^0.4.2", "@expo-google-fonts/source-sans-3": "^0.4.1", "@expo/html-elements": "^0.12.5", @@ -86,9 +87,9 @@ "@react-stately/toggle": "^3.10.1", "ai": "^6.0.214", "expo-document-picker": "~57.0.1", + "expo-font": "~57.0.1", "expo-glass-effect": "~57.0.1", "expo-image-picker": "~57.0.6", - "expo-font": "~57.0.1", "lucide-react-native": "^1.22.0", "react-dom": "^19.2.3", "react-native-gesture-handler": "~2.32.0", diff --git a/packages/ui/theme/index.tsx b/packages/ui/theme/index.tsx index e21b9a0c..40d90327 100644 --- a/packages/ui/theme/index.tsx +++ b/packages/ui/theme/index.tsx @@ -6,12 +6,15 @@ import { SourceSans3_400Regular } from '@expo-google-fonts/source-sans-3/400Regu import { SourceSans3_500Medium } from '@expo-google-fonts/source-sans-3/500Medium'; import { SourceSans3_600SemiBold } from '@expo-google-fonts/source-sans-3/600SemiBold'; import { SourceSans3_700Bold } from '@expo-google-fonts/source-sans-3/700Bold'; +import { COLOR_PRESETS } from '@eight2five/drill-schema'; import { useFonts } from 'expo-font'; import React from 'react'; import { useColorScheme, type ColorSchemeName } from 'react-native'; +export const eight2FiveDrillColors = COLOR_PRESETS; + export const eight2FiveBaseColors = { - blue: '#3C6EC8', + blue: eight2FiveDrillColors.blue, blueSecondary: '#3264BE', white: '#FFFFFF', black: '#000000', diff --git a/packages/ui/theme/theme.css b/packages/ui/theme/theme.css index 23454c35..11767ddf 100644 --- a/packages/ui/theme/theme.css +++ b/packages/ui/theme/theme.css @@ -1,6 +1,11 @@ +:root { + /* COLOR_PRESETS.blue (#3C6EC8), shared by drill entities and UI accents. */ + --eight2five-drill-blue: 60 110 200; +} + @layer theme { :where(.light, .light *) { - --primary: 60 110 200; + --primary: var(--eight2five-drill-blue); --primary-foreground: 255 255 255; --card: 255 255 255; --secondary: 218 218 218; @@ -14,14 +19,14 @@ --foreground: 30 30 30; --border: 218 218 218; --input: 243 243 243; - --ring: 60 110 200; + --ring: var(--eight2five-drill-blue); --accent: 243 243 243; --accent-foreground: 30 30 30; } @media (prefers-color-scheme: light) { :root:not(:where(.light, .light *, .dark, .dark *)) { - --primary: 60 110 200; + --primary: var(--eight2five-drill-blue); --primary-foreground: 255 255 255; --card: 255 255 255; --secondary: 218 218 218; @@ -35,14 +40,14 @@ --foreground: 30 30 30; --border: 218 218 218; --input: 243 243 243; - --ring: 60 110 200; + --ring: var(--eight2five-drill-blue); --accent: 243 243 243; --accent-foreground: 30 30 30; } } :where(.dark, .dark *) { - --primary: 60 110 200; + --primary: var(--eight2five-drill-blue); --primary-foreground: 255 255 255; --card: 36 36 36; --secondary: 51 51 51; @@ -56,14 +61,14 @@ --foreground: 255 255 255; --border: 51 51 51; --input: 51 51 51; - --ring: 60 110 200; + --ring: var(--eight2five-drill-blue); --accent: 51 51 51; --accent-foreground: 255 255 255; } @media (prefers-color-scheme: dark) { :root:not(:where(.light, .light *, .dark, .dark *)) { - --primary: 60 110 200; + --primary: var(--eight2five-drill-blue); --primary-foreground: 255 255 255; --card: 36 36 36; --secondary: 51 51 51; @@ -77,7 +82,7 @@ --foreground: 255 255 255; --border: 51 51 51; --input: 51 51 51; - --ring: 60 110 200; + --ring: var(--eight2five-drill-blue); --accent: 51 51 51; --accent-foreground: 255 255 255; } From d954a06ea518da96bf34332c120b42a22e39ffd8 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 04:08:35 -0500 Subject: [PATCH 062/101] fix(drill-schema): Version custom marking contract Publish required custom field markings as schema version 2.0.0, verify resolved custom propagation and back-hash symmetry, and derive soft UI accents from the canonical drill blue. --- .../src/converter/__tests__/settings.test.ts | 3 ++- packages/drill-schema/drill-document.schema.json | 4 ++-- packages/drill-schema/src/__tests__/schema.test.ts | 13 ++++++++++++- packages/drill-schema/src/types.ts | 2 +- packages/ui/theme/index.tsx | 11 +++++++++-- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/drill-converter/src/converter/__tests__/settings.test.ts b/apps/drill-converter/src/converter/__tests__/settings.test.ts index 25300854..a7fc9be5 100644 --- a/apps/drill-converter/src/converter/__tests__/settings.test.ts +++ b/apps/drill-converter/src/converter/__tests__/settings.test.ts @@ -1,5 +1,6 @@ import { COLOR_PRESETS, + DRILL_SCHEMA_VERSION, FIELD_PRESET_IDS, parseDrillDocument, resolveDrillEntity, @@ -18,7 +19,7 @@ import { const source: DrillDocument = parseDrillDocument({ schema: "https://eight2five.com/schema/drill", - schemaVersion: "1.0.0", + schemaVersion: DRILL_SCHEMA_VERSION, metadata: { title: "Imported", createdAt: "2026-08-02T18:00:00.000Z", diff --git a/packages/drill-schema/drill-document.schema.json b/packages/drill-schema/drill-document.schema.json index b6f5ae6c..da5d89db 100644 --- a/packages/drill-schema/drill-document.schema.json +++ b/packages/drill-schema/drill-document.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://eight2five.com/schema/drill/1.0.0", + "$id": "https://eight2five.com/schema/drill/2.0.0", "title": "Eight2Five Drill Document", "type": "object", "additionalProperties": false, @@ -18,7 +18,7 @@ "const": "https://eight2five.com/schema/drill" }, "schemaVersion": { - "const": "1.0.0" + "const": "2.0.0" }, "metadata": { "$ref": "#/$defs/metadata" diff --git a/packages/drill-schema/src/__tests__/schema.test.ts b/packages/drill-schema/src/__tests__/schema.test.ts index e6de8f13..29b26e49 100644 --- a/packages/drill-schema/src/__tests__/schema.test.ts +++ b/packages/drill-schema/src/__tests__/schema.test.ts @@ -13,6 +13,7 @@ import { parseDrillDocument, physicalPointToDrillGrid, resolveDrillEntity, + resolveFieldDefinition, resolveEntityRuleValues, resolvePropSize, serializeDrillDocument, @@ -21,7 +22,7 @@ import { const fixture: DrillDocument = { schema: "https://eight2five.com/schema/drill", - schemaVersion: "1.0.0", + schemaVersion: "2.0.0", metadata: { title: "Part 4", createdAt: "2026-08-02T17:30:00.000Z", @@ -305,7 +306,14 @@ describe("drill schema", () => { const frontHash = field.physicalGeometry.referenceLines.find( (line) => line.id === "front-hash", ); + const backHash = field.physicalGeometry.referenceLines.find( + (line) => line.id === "back-hash", + ); expect(frontHash?.coordinateMeters).toBeCloseTo(hashFeet * 0.3048, 8); + expect(backHash?.coordinateMeters).toBeCloseTo( + 160 * 0.3048 - hashFeet * 0.3048, + 8, + ); expect(field.markings).toMatchObject({ yardNumbers: { heightMeters: 6 * 0.3048, @@ -345,6 +353,9 @@ describe("drill schema", () => { type: "custom", markings: preset.markings, }); + expect(resolveFieldDefinition(parsed.field).markings).toEqual( + preset.markings, + ); }); it.each(FIELD_PRESET_IDS)( diff --git a/packages/drill-schema/src/types.ts b/packages/drill-schema/src/types.ts index 2b9c7832..d5a4b89b 100644 --- a/packages/drill-schema/src/types.ts +++ b/packages/drill-schema/src/types.ts @@ -1,5 +1,5 @@ export const DRILL_SCHEMA_URL = "https://eight2five.com/schema/drill" as const; -export const DRILL_SCHEMA_VERSION = "1.0.0" as const; +export const DRILL_SCHEMA_VERSION = "2.0.0" as const; export type SetKind = "set" | "subset"; export type DrillEntityType = "performer" | "prop"; diff --git a/packages/ui/theme/index.tsx b/packages/ui/theme/index.tsx index 40d90327..5b2a36f8 100644 --- a/packages/ui/theme/index.tsx +++ b/packages/ui/theme/index.tsx @@ -88,6 +88,13 @@ export const eight2FiveFonts = { utilityBold: 'SourceSans3_700Bold', } as const; +function colorWithOpacity(color: `#${string}`, opacity: number): string { + const red = Number.parseInt(color.slice(1, 3), 16); + const green = Number.parseInt(color.slice(3, 5), 16); + const blue = Number.parseInt(color.slice(5, 7), 16); + return `rgba(${red}, ${green}, ${blue}, ${opacity})`; +} + export const eight2FiveThemes = { light: { raw: eight2FiveLightColors, @@ -102,7 +109,7 @@ export const eight2FiveThemes = { border: eight2FiveLightColors.secondary, accent: eight2FiveLightColors.blue, accentPressed: eight2FiveLightColors.blueSecondary, - accentSoft: 'rgba(60, 110, 200, 0.12)', + accentSoft: colorWithOpacity(eight2FiveDrillColors.blue, 0.12), danger: eight2FiveLightColors.danger, dangerSoft: 'rgba(200, 60, 60, 0.12)', warning: eight2FiveLightColors.warning, @@ -125,7 +132,7 @@ export const eight2FiveThemes = { border: eight2FiveDarkColors.primary, accent: eight2FiveDarkColors.blue, accentPressed: eight2FiveDarkColors.blueSecondary, - accentSoft: 'rgba(60, 110, 200, 0.20)', + accentSoft: colorWithOpacity(eight2FiveDrillColors.blue, 0.2), danger: eight2FiveDarkColors.danger, dangerSoft: 'rgba(200, 60, 60, 0.18)', warning: eight2FiveDarkColors.warning, From 22637000b703a7058f2070ab011c133ec6475c9b Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 04:16:08 -0500 Subject: [PATCH 063/101] fix(ui): Guard drill color synchronization Derive default field colors from drill-schema presets and add a parity test that keeps the CSS accent token synchronized with the canonical blue. --- .../src/state/__tests__/appearance-theme.test.ts | 13 +++++++++++++ .../mobile/src/field/render/field-render-tokens.ts | 13 +++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/__tests__/appearance-theme.test.ts b/apps/mobile/src/state/__tests__/appearance-theme.test.ts index d636ee75..8b3f48a6 100644 --- a/apps/mobile/src/state/__tests__/appearance-theme.test.ts +++ b/apps/mobile/src/state/__tests__/appearance-theme.test.ts @@ -1,3 +1,7 @@ +/// + +import { readFileSync } from "node:fs"; +import path from "node:path"; import { eight2FiveDrillColors, eight2FiveThemes, @@ -25,5 +29,14 @@ describe("app appearance", () => { expect(eight2FiveDrillColors).toBe(COLOR_PRESETS); expect(eight2FiveThemes.light.accent).toBe(COLOR_PRESETS.blue); expect(eight2FiveThemes.dark.accent).toBe(COLOR_PRESETS.blue); + + const css = readFileSync( + path.resolve(__dirname, "../../../../../packages/ui/theme/theme.css"), + "utf8", + ); + const red = Number.parseInt(COLOR_PRESETS.blue.slice(1, 3), 16); + const green = Number.parseInt(COLOR_PRESETS.blue.slice(3, 5), 16); + const blue = Number.parseInt(COLOR_PRESETS.blue.slice(5, 7), 16); + expect(css).toContain(`--eight2five-drill-blue: ${red} ${green} ${blue};`); }); }); diff --git a/packages/mobile/src/field/render/field-render-tokens.ts b/packages/mobile/src/field/render/field-render-tokens.ts index 08642685..f9875488 100644 --- a/packages/mobile/src/field/render/field-render-tokens.ts +++ b/packages/mobile/src/field/render/field-render-tokens.ts @@ -1,5 +1,14 @@ +import { COLOR_PRESETS } from "@eight2five/drill-schema"; + export const FIELD_FOUR_STEP_GRID_COLOR = "#6FA0E1"; +function colorWithOpacity(color: `#${string}`, opacity: number): string { + const red = Number.parseInt(color.slice(1, 3), 16); + const green = Number.parseInt(color.slice(3, 5), 16); + const blue = Number.parseInt(color.slice(5, 7), 16); + return `rgba(${red}, ${green}, ${blue}, ${opacity})`; +} + export interface FieldRenderPalette { readonly canvasBackground: string; readonly stepGrid: string; @@ -21,9 +30,9 @@ export const DEFAULT_FIELD_RENDER_PALETTE: FieldRenderPalette = Object.freeze({ fourStepGrid: FIELD_FOUR_STEP_GRID_COLOR, fieldLines: "#5D6470", fieldNumbers: "#69717D", - livePosition: "#3C6EC8", + livePosition: COLOR_PRESETS.blue, target: "#D29B22", - guidance: "rgba(60, 110, 200, 0.74)", + guidance: colorWithOpacity(COLOR_PRESETS.blue, 0.74), anchor: "#7B5CC7", anchorRange: "rgba(123, 92, 199, 0.14)", }); From bb02b6f0ff0956f1d41e67fdcd811d72442bef57 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 04:20:22 -0500 Subject: [PATCH 064/101] fix(drill-converter): Share canonical accent color Use drill-schema's blue preset for converter accents and color-entry guidance so the editing UI cannot drift from exported entity colors. --- .../src/components/entity-settings-section.tsx | 3 ++- apps/drill-converter/src/ui/theme.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/drill-converter/src/components/entity-settings-section.tsx b/apps/drill-converter/src/components/entity-settings-section.tsx index 33cf083c..d4ce36a6 100644 --- a/apps/drill-converter/src/components/entity-settings-section.tsx +++ b/apps/drill-converter/src/components/entity-settings-section.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Pressable, Text, View } from "react-native"; import { + COLOR_PRESETS, convertPropSizeValue, type PropSizeUnit, } from "@eight2five/drill-schema"; @@ -417,7 +418,7 @@ function RuleEditor({ onChangeText={(color) => onUpdate({ color })} autoCapitalize="none" autoCorrect={false} - placeholder="#3C6EC8" + placeholder={COLOR_PRESETS.blue} helper="Leave blank for Default (Grey) or the selected preset. Any six-digit hex color is valid." /> diff --git a/apps/drill-converter/src/ui/theme.ts b/apps/drill-converter/src/ui/theme.ts index 37436d44..a29236f1 100644 --- a/apps/drill-converter/src/ui/theme.ts +++ b/apps/drill-converter/src/ui/theme.ts @@ -1,3 +1,5 @@ +import { COLOR_PRESETS } from "@eight2five/drill-schema"; + export const colors = Object.freeze({ page: "#f4f6fa", surface: "#ffffff", @@ -6,7 +8,7 @@ export const colors = Object.freeze({ textMuted: "#64748b", border: "#d9e0ea", borderStrong: "#b8c4d4", - accent: "#3c6ec8", + accent: COLOR_PRESETS.blue, accentSoft: "#edf3ff", accentText: "#234b92", success: "#16794a", From 5a89e1a90928273a094060a6de648aadb186d6ac Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 04:54:16 -0500 Subject: [PATCH 065/101] fix(field): Correct football markings and camera bounds Render schema-defined number, inbounds hash, and sideline geometry while preserving natural glyph proportions. Wire auxiliary visibility, expand exterior camera travel, and use Hold in transition presentation. --- .../drill/__tests__/page-ordering.test.ts | 6 +- .../features/drill/transition-presentation.ts | 2 +- .../src/features/field/field-screen.tsx | 1 + apps/testbed/jest.setup.ts | 10 +- .../field/__tests__/field-camera-math.test.ts | 56 ++++++++ .../src/field/__tests__/field-paths.test.ts | 71 +++++++++- .../src/field/__tests__/template.test.ts | 47 ++++++- .../__tests__/yard-number-layout.test.ts | 74 +++++++++++ .../src/field/camera/field-camera-policy.ts | 4 +- .../src/field/render/create-field-paths.ts | 124 ++++++++++++++++-- .../mobile/src/field/render/field-canvas.tsx | 3 + .../mobile/src/field/render/field-scene.tsx | 3 + .../src/field/render/field-static-layer.tsx | 51 +++++-- packages/mobile/src/field/render/index.ts | 1 + .../src/field/render/yard-number-layout.ts | 54 ++++++++ packages/mobile/src/field/template.ts | 57 +++++--- 16 files changed, 511 insertions(+), 53 deletions(-) create mode 100644 packages/mobile/src/field/__tests__/yard-number-layout.test.ts create mode 100644 packages/mobile/src/field/render/yard-number-layout.ts diff --git a/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts b/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts index 79650cb5..4503ab38 100644 --- a/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts +++ b/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts @@ -118,7 +118,7 @@ describe("set ordering and transition presentation", () => { expect(order).toEqual(["delete", "reload"]); }); - test("formats unavailable, Halt, Step Size, and xCounts values", () => { + test("formats unavailable, Hold, Step Size, and xCounts values", () => { const base: TransitionAnalysis = { distanceSteps: 8, stepSizeToFive: 6.5, @@ -143,7 +143,7 @@ describe("set ordering and transition presentation", () => { true, 16, ).stepSize, - ).toBe("Halt"); + ).toBe("Hold"); }); test("recalculates both transitions neighboring a changed middle set", () => { @@ -158,7 +158,7 @@ describe("set ordering and transition presentation", () => { expect(originalMiddle.stepSize).toBe("8 to 5"); expect(originalFollowing.stepSize).toBe("8 to 5"); - expect(nextMiddle.stepSize).toBe("Halt"); + expect(nextMiddle.stepSize).toBe("Hold"); expect(nextFollowing.stepSize).toBe("4 to 5"); }); }); diff --git a/apps/mobile/src/features/drill/transition-presentation.ts b/apps/mobile/src/features/drill/transition-presentation.ts index 5a5966da..1084f09b 100644 --- a/apps/mobile/src/features/drill/transition-presentation.ts +++ b/apps/mobile/src/features/drill/transition-presentation.ts @@ -19,7 +19,7 @@ export function formatTransitionAnalysis( } return { stepSize: analysis.isHalt - ? "Halt" + ? "Hold" : analysis.stepSizeToFive === undefined ? "–" : `${formatMetricNumber(analysis.stepSizeToFive)} to 5`, diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index 8bb5c660..a038ede9 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -111,6 +111,7 @@ export function FieldScreen({ guidanceVisible={shouldShowFieldGuidance(drillOverlayState)} anchors={anchors} anchorOverlayOptions={anchorOverlayOptions} + showAuxiliaryFieldMarks={controller.settings.showAuxiliaryFieldMarks} showPerimeterStepGrid={ controller.settings.developerModeEnabled && controller.settings.showPerimeterStepGrid diff --git a/apps/testbed/jest.setup.ts b/apps/testbed/jest.setup.ts index 3b82518b..8dae30b9 100644 --- a/apps/testbed/jest.setup.ts +++ b/apps/testbed/jest.setup.ts @@ -68,9 +68,17 @@ jest.mock( Group: MockSkiaNode, Line: MockSkiaNode, Path: MockSkiaNode, + Text: MockSkiaNode, Circle: MockSkiaNode, LinearGradient: MockSkiaNode, - useFont: () => ({}), + useFont: () => ({ + measureText: (text: string) => ({ + x: 0, + y: -0.75, + width: text.length * 0.6, + height: 0.75, + }), + }), vec: (x: number, y: number) => ({ x, y }), }; }, diff --git a/packages/mobile/src/field/__tests__/field-camera-math.test.ts b/packages/mobile/src/field/__tests__/field-camera-math.test.ts index 435011b5..ee4d5728 100644 --- a/packages/mobile/src/field/__tests__/field-camera-math.test.ts +++ b/packages/mobile/src/field/__tests__/field-camera-math.test.ts @@ -9,13 +9,19 @@ import { fieldWorldToScreen, } from "../camera/field-camera-math"; import { + FIELD_CAMERA_BLANK_MARGIN_YARDS, + FIELD_CAMERA_TOTAL_EXTERIOR_ALLOWANCE_YARDS, + FIELD_GRID_PERIMETER_YARDS, FIELD_MIN_METERS_PER_PIXEL, getFieldCameraBounds, getFieldGridBounds, getFieldMaximumMetersPerPixel, } from "../camera/field-camera-policy"; +import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "../template"; +import { metersToYards } from "../units"; const size = { width: 800, height: 400 }; +const portraitSize = { width: 400, height: 800 }; const viewport = { centerXMeters: 45, centerYMeters: 20, @@ -84,6 +90,32 @@ describe("field camera math", () => { ).toMatchObject({ centerXMeters: 50, centerYMeters: 25 }); }); + test("clamps panning to the exterior camera allowance in both orientations", () => { + const bounds = getFieldCameraBounds(); + const metersPerPixel = 0.1; + + for (const currentSize of [size, portraitSize]) { + const clamped = clampFieldViewport( + { + centerXMeters: bounds.minXMeters - 100, + centerYMeters: bounds.maxYMeters + 100, + metersPerPixel, + }, + currentSize, + bounds, + ); + + expect(clamped.centerXMeters).toBeCloseTo( + bounds.minXMeters + (currentSize.width * metersPerPixel) / 2, + 10, + ); + expect(clamped.centerYMeters).toBeCloseTo( + bounds.maxYMeters - (currentSize.height * metersPerPixel) / 2, + 10, + ); + } + }); + test("rebases pan translation after a pinch pointer transition", () => { const current = { xMeters: 30, yMeters: 12 }; const rebased = createFieldPanBaseline(current, 84, -20, 0.1); @@ -105,4 +137,28 @@ describe("field camera math", () => { expect(cameraBounds.minXMeters).toBeLessThan(gridBounds.minXMeters); expect(cameraBounds.maxYMeters).toBeGreaterThan(gridBounds.maxYMeters); }); + + test("keeps the rendered grid at 10 yards and allows 30 yards outside the field", () => { + const gridBounds = getFieldGridBounds(); + const cameraBounds = getFieldCameraBounds(); + + expect(FIELD_GRID_PERIMETER_YARDS).toBe(10); + expect(FIELD_CAMERA_BLANK_MARGIN_YARDS).toBe(20); + expect(FIELD_CAMERA_TOTAL_EXTERIOR_ALLOWANCE_YARDS).toBe(30); + expect( + metersToYards( + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE.bounds.minXMeters - + gridBounds.minXMeters, + ), + ).toBeCloseTo(FIELD_GRID_PERIMETER_YARDS, 10); + expect( + metersToYards( + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE.bounds.minXMeters - + cameraBounds.minXMeters, + ), + ).toBeCloseTo(FIELD_CAMERA_TOTAL_EXTERIOR_ALLOWANCE_YARDS, 10); + expect( + metersToYards(gridBounds.minXMeters - cameraBounds.minXMeters), + ).toBeCloseTo(FIELD_CAMERA_BLANK_MARGIN_YARDS, 10); + }); }); diff --git a/packages/mobile/src/field/__tests__/field-paths.test.ts b/packages/mobile/src/field/__tests__/field-paths.test.ts index 3cbe82c8..2c2bd30c 100644 --- a/packages/mobile/src/field/__tests__/field-paths.test.ts +++ b/packages/mobile/src/field/__tests__/field-paths.test.ts @@ -188,22 +188,87 @@ describe("aggregate field paths", () => { }, ); - test("keeps physical football marks aggregate and exposes stable shape counts", () => { + test("renders perpendicular inbounds hashes without treating guides as ticks", () => { const paths = createFieldPaths(field); + const hashSegments = parseSubpaths(paths.hashMarksPath); expect(subpathCount(paths.yardLinesPath)).toBe(19); expect(paths.counts.yardLines.lineCount).toBe(19); - expect(subpathCount(paths.hashMarksPath)).toBe(198); + expect(hashSegments).toHaveLength(198); + expect( + hashSegments.every( + ({ x1, y1, x2, y2 }) => + y1 === y2 && + Math.abs( + x2 - + x1 - + field.fieldDefinition.markings.inboundsHashMarks.lengthMeters, + ) < 1e-6, + ), + ).toBe(true); expect(paths.counts.hashMarks).toMatchObject({ rowCount: 2, ticksPerRow: 99, tickCount: 198, - spacingMeters: yardsToMeters(1), + spacingMeters: + field.fieldDefinition.markings.inboundsHashMarks.spacingMeters, + tickLengthMeters: + field.fieldDefinition.markings.inboundsHashMarks.lengthMeters, }); + expect(subpathCount(paths.hashGuideLinesPath)).toBe(2); + expect(paths.counts.hashGuideLines.lineCount).toBe(2); expect(subpathCount(paths.boundaryPath)).toBe(1); expect(paths.boundaryPath.endsWith(" Z")).toBe(true); expect(paths.counts.boundary.segmentCount).toBe(1); }); + + test.each(PRESETS)( + "%s renders preset sideline marks at one-yard positions between full yard lines", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + const paths = createFieldPaths(template); + const segments = parseSubpaths(paths.sidelineHashMarksPath); + const markings = template.fieldDefinition.markings.sidelineHashMarks; + + expect(segments).toHaveLength(160); + expect(paths.counts.sidelineHashMarks).toEqual({ + spacingMeters: markings.spacingMeters, + markLengthMeters: markings.lengthMeters, + insetFromSidelineMeters: markings.insetFromSidelineMeters, + rowCount: 2, + marksPerRow: 80, + markCount: 160, + }); + expect( + segments.every( + ({ x1, y1, x2, y2 }) => + x1 === x2 && + Math.abs(Math.abs(y2 - y1) - markings.lengthMeters) < 1e-6, + ), + ).toBe(true); + expect( + segments.every(({ x1 }) => { + const yardsFromGoalLine = + (x1 - template.bounds.minXMeters) / yardsToMeters(1); + return Math.abs(yardsFromGoalLine % 5) > 1e-6; + }), + ).toBe(true); + const front = segments.find( + ({ y1, y2 }) => + y1 < template.widthMeters / 2 && y2 < template.widthMeters / 2, + )!; + const back = segments.find( + ({ y1, y2 }) => + y1 > template.widthMeters / 2 && y2 > template.widthMeters / 2, + )!; + expect(front.y1 - template.bounds.minYMeters).toBeCloseTo( + markings.insetFromSidelineMeters, + ); + expect(template.bounds.maxYMeters - back.y2).toBeCloseTo( + markings.insetFromSidelineMeters, + ); + }, + ); }); function gridReference( diff --git a/packages/mobile/src/field/__tests__/template.test.ts b/packages/mobile/src/field/__tests__/template.test.ts index 2cd16577..c25d83d8 100644 --- a/packages/mobile/src/field/__tests__/template.test.ts +++ b/packages/mobile/src/field/__tests__/template.test.ts @@ -1,4 +1,5 @@ import { + createStandardFootballFieldTemplate, STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, getStandardFieldDimensionsInFeet, getStandardFieldDimensionsInYards, @@ -41,12 +42,29 @@ describe("standard high-school field template", () => { expect(field.fiveYardLines[18].start.xMeters).toBeCloseTo(41.148); }); - test("includes two dimensioned numbers for each standard number position", () => { + test("includes both number rows from goal line zero through the 50", () => { const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; - expect(field.yardNumbers).toHaveLength(18); + expect(field.yardNumbers).toHaveLength(22); + expect( + field.yardNumbers.filter((number) => number.label === "0"), + ).toHaveLength(4); expect( field.yardNumbers.filter((number) => number.label === "50"), ).toHaveLength(2); + expect( + field.yardNumbers + .filter((number) => number.side === "front") + .map((number) => number.label), + ).toEqual(["0", "10", "20", "30", "40", "50", "40", "30", "20", "10", "0"]); + expect( + field.yardNumbers.every( + (number) => + number.xMeters === + field.allFiveYardLines.find( + (line) => line.coordinateMeters === number.xMeters, + )?.coordinateMeters, + ), + ).toBe(true); expect(field.yardNumbers.every((number) => number.widthMeters > 0)).toBe( true, ); @@ -55,6 +73,31 @@ describe("standard high-school field template", () => { ); }); + test.each([ + ["football-nfhs", 24], + ["football-ncaa", 24], + ["football-texas-uil", 24], + ["football-nfl", 39], + ] as const)("%s uses schema-defined number centers", (preset, centerFeet) => { + const field = createStandardFootballFieldTemplate(preset); + const front = field.yardNumbers.find((number) => number.side === "front")!; + const back = field.yardNumbers.find((number) => number.side === "back")!; + + expect(field.dimensions.yardNumberHeightFeet).toBeCloseTo(6); + expect(field.dimensions.yardNumberCenterFromFrontSidelineFeet).toBeCloseTo( + centerFeet, + ); + expect(field.dimensions.yardNumberCenterFromBackSidelineFeet).toBeCloseTo( + centerFeet, + ); + expect(front.yMeters - field.bounds.minYMeters).toBeCloseTo( + field.fieldDefinition.markings.yardNumbers.centerFromFrontSidelineMeters, + ); + expect(field.bounds.maxYMeters - back.yMeters).toBeCloseTo( + field.fieldDefinition.markings.yardNumbers.centerFromBackSidelineMeters, + ); + }); + test("is deeply immutable", () => { const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE as any; expect(Object.isFrozen(field)).toBe(true); diff --git a/packages/mobile/src/field/__tests__/yard-number-layout.test.ts b/packages/mobile/src/field/__tests__/yard-number-layout.test.ts new file mode 100644 index 00000000..5a84a0c4 --- /dev/null +++ b/packages/mobile/src/field/__tests__/yard-number-layout.test.ts @@ -0,0 +1,74 @@ +import { createYardNumberTextLayout } from "../render/yard-number-layout"; +import { feetToMeters } from "../units"; + +const bounds = { x: 0.1, y: -0.8, width: 1.4, height: 0.9 }; +const targetHeightMeters = feetToMeters(6); + +describe("yard-number text layout", () => { + test.each(["front", "back"] as const)( + "centers measured %s glyph bounds at an exact six-foot visual height", + (side) => { + const layout = createYardNumberTextLayout( + bounds, + targetHeightMeters, + side, + ); + const transformedCorners = [ + { + x: (layout.x + bounds.x) * layout.scaleX, + y: (layout.y + bounds.y) * layout.scaleY, + }, + { + x: (layout.x + bounds.x + bounds.width) * layout.scaleX, + y: (layout.y + bounds.y + bounds.height) * layout.scaleY, + }, + ]; + + expect( + (transformedCorners[0].x + transformedCorners[1].x) / 2, + ).toBeCloseTo(0); + expect( + (transformedCorners[0].y + transformedCorners[1].y) / 2, + ).toBeCloseTo(0); + expect(layout.visualHeightMeters).toBeCloseTo(targetHeightMeters); + expect( + Math.abs(transformedCorners[1].y - transformedCorners[0].y), + ).toBeCloseTo(targetHeightMeters); + }, + ); + + test("faces the front and back rows toward opposite sidelines", () => { + const front = createYardNumberTextLayout( + bounds, + targetHeightMeters, + "front", + ); + const back = createYardNumberTextLayout(bounds, targetHeightMeters, "back"); + + expect(front.scaleX).toBeGreaterThan(0); + expect(front.scaleY).toBeLessThan(0); + expect(back.scaleX).toBeLessThan(0); + expect(back.scaleY).toBeGreaterThan(0); + + // FieldScene reflects world Y into screen Y. The front row is upright on + // screen; the back row is rotated 180 degrees to face the back sideline. + expect({ x: front.scaleX, y: -front.scaleY }).toEqual({ + x: Math.abs(front.scaleX), + y: Math.abs(front.scaleY), + }); + expect({ x: back.scaleX, y: -back.scaleY }).toEqual({ + x: -Math.abs(back.scaleX), + y: -Math.abs(back.scaleY), + }); + }); + + test("rejects unusable visual bounds", () => { + expect(() => + createYardNumberTextLayout( + { ...bounds, height: 0 }, + targetHeightMeters, + "front", + ), + ).toThrow(RangeError); + }); +}); diff --git a/packages/mobile/src/field/camera/field-camera-policy.ts b/packages/mobile/src/field/camera/field-camera-policy.ts index 54e300e9..b321669f 100644 --- a/packages/mobile/src/field/camera/field-camera-policy.ts +++ b/packages/mobile/src/field/camera/field-camera-policy.ts @@ -10,7 +10,9 @@ import type { } from "./field-camera-types"; export const FIELD_GRID_PERIMETER_YARDS = 10; -export const FIELD_CAMERA_BLANK_MARGIN_YARDS = 5; +export const FIELD_CAMERA_BLANK_MARGIN_YARDS = 20; +export const FIELD_CAMERA_TOTAL_EXTERIOR_ALLOWANCE_YARDS = + FIELD_GRID_PERIMETER_YARDS + FIELD_CAMERA_BLANK_MARGIN_YARDS; export const FIELD_MIN_METERS_PER_PIXEL = 0.02; export const FIELD_ZOOM_OUT_BREATHING_ROOM = 1.2; export const FIELD_INITIAL_BREATHING_ROOM = 1.06; diff --git a/packages/mobile/src/field/render/create-field-paths.ts b/packages/mobile/src/field/render/create-field-paths.ts index 104db630..22f063d1 100644 --- a/packages/mobile/src/field/render/create-field-paths.ts +++ b/packages/mobile/src/field/render/create-field-paths.ts @@ -7,11 +7,9 @@ import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, type StandardFootballFieldTemplate, } from "../template"; -import { feetToMeters, yardsToMeters } from "../units"; +import { yardsToMeters } from "../units"; const GRID_PADDING_YARDS = 10; -const HASH_MARK_SPACING_METERS = yardsToMeters(1); -const HASH_MARK_LENGTH_METERS = feetToMeters(2); const PATH_NUMBER_PRECISION = 1_000_000; const COORDINATE_EPSILON = 1e-9; const FOUR_STEP_INTERVAL = 4; @@ -53,6 +51,19 @@ export interface HashMarksPathMetadata { readonly tickCount: number; } +export interface HashGuideLinesPathMetadata { + readonly lineCount: 2; +} + +export interface SidelineHashMarksPathMetadata { + readonly spacingMeters: number; + readonly markLengthMeters: number; + readonly insetFromSidelineMeters: number; + readonly rowCount: 2; + readonly marksPerRow: number; + readonly markCount: number; +} + export interface BoundaryPathMetadata { readonly segmentCount: 1; } @@ -63,6 +74,8 @@ export interface FieldPathCounts { readonly fourStepGrid: FourStepGridPathMetadata; readonly yardLines: YardLinesPathMetadata; readonly hashMarks: HashMarksPathMetadata; + readonly hashGuideLines: HashGuideLinesPathMetadata; + readonly sidelineHashMarks: SidelineHashMarksPathMetadata; readonly boundary: BoundaryPathMetadata; } @@ -75,7 +88,10 @@ export interface FieldPaths { /** Four marching-grid steps, clipped to the physical field. */ readonly fourStepGridPath: string; readonly yardLinesPath: string; + /** Perpendicular inbounds hashes that remain visible with auxiliaries off. */ readonly hashMarksPath: string; + readonly hashGuideLinesPath: string; + readonly sidelineHashMarksPath: string; readonly boundaryPath: string; readonly fieldExtent: FieldPathExtent; readonly gridExtent: FieldPathExtent; @@ -92,6 +108,8 @@ export interface FieldPaths { readonly fourStepGrid: string; readonly yardLines: string; readonly hashMarks: string; + readonly hashGuideLines: string; + readonly sidelineHashMarks: string; readonly boundary: string; } @@ -185,21 +203,67 @@ export function createFieldPaths( template.frontHashLine.coordinateMeters, template.backHashLine.coordinateMeters, ] as const; + const inboundsMarkings = template.fieldDefinition.markings.inboundsHashMarks; const hashMarks: string[] = []; - const ticksPerRow = Math.max(0, Math.ceil(template.goalToGoalYards) - 1); + const inboundsXCoordinates = spacedInteriorCoordinates( + fieldExtent.minXMeters, + fieldExtent.maxXMeters, + inboundsMarkings.spacingMeters, + ); for (const yMeters of hashYCoordinates) { - for (let yard = 1; yard < template.goalToGoalYards; yard += 1) { - const xMeters = fieldExtent.minXMeters + yard * HASH_MARK_SPACING_METERS; + for (const xMeters of inboundsXCoordinates) { hashMarks.push( - verticalSegment( - xMeters, - yMeters - HASH_MARK_LENGTH_METERS / 2, - yMeters + HASH_MARK_LENGTH_METERS / 2, + horizontalSegment( + xMeters - inboundsMarkings.lengthMeters / 2, + yMeters, + xMeters + inboundsMarkings.lengthMeters / 2, ), ); } } const hashMarksPath = hashMarks.join(" "); + const hashGuideLinesPath = template.hashLines + .map((line) => + horizontalSegment( + fieldExtent.minXMeters, + line.coordinateMeters, + fieldExtent.maxXMeters, + ), + ) + .join(" "); + + const sidelineMarkings = template.fieldDefinition.markings.sidelineHashMarks; + const sidelineXCoordinates = spacedInteriorCoordinates( + fieldExtent.minXMeters, + fieldExtent.maxXMeters, + sidelineMarkings.spacingMeters, + ).filter( + (xMeters) => + !isMultipleOfSpacing( + xMeters - fieldExtent.minXMeters, + template.dimensions.fiveYardLineSpacingMeters, + ), + ); + const sidelineHashMarks: string[] = []; + for (const xMeters of sidelineXCoordinates) { + const frontStart = + fieldExtent.minYMeters + sidelineMarkings.insetFromSidelineMeters; + const backStart = + fieldExtent.maxYMeters - sidelineMarkings.insetFromSidelineMeters; + sidelineHashMarks.push( + verticalSegment( + xMeters, + frontStart, + frontStart + sidelineMarkings.lengthMeters, + ), + verticalSegment( + xMeters, + backStart - sidelineMarkings.lengthMeters, + backStart, + ), + ); + } + const sidelineHashMarksPath = sidelineHashMarks.join(" "); const boundaryPath = rectanglePath(fieldExtent); const extents = Object.freeze({ field: fieldExtent, grid: gridExtent }); @@ -224,12 +288,21 @@ export function createFieldPaths( }), yardLines: Object.freeze({ lineCount: template.yardLines.length }), hashMarks: Object.freeze({ - spacingMeters: HASH_MARK_SPACING_METERS, - tickLengthMeters: HASH_MARK_LENGTH_METERS, + spacingMeters: inboundsMarkings.spacingMeters, + tickLengthMeters: inboundsMarkings.lengthMeters, rowCount: 2, - ticksPerRow, + ticksPerRow: inboundsXCoordinates.length, tickCount: hashMarks.length, }), + hashGuideLines: Object.freeze({ lineCount: 2 }), + sidelineHashMarks: Object.freeze({ + spacingMeters: sidelineMarkings.spacingMeters, + markLengthMeters: sidelineMarkings.lengthMeters, + insetFromSidelineMeters: sidelineMarkings.insetFromSidelineMeters, + rowCount: 2, + marksPerRow: sidelineXCoordinates.length, + markCount: sidelineHashMarks.length, + }), boundary: Object.freeze({ segmentCount: 1 }), }); @@ -239,6 +312,8 @@ export function createFieldPaths( fourStepGridPath, yardLinesPath, hashMarksPath, + hashGuideLinesPath, + sidelineHashMarksPath, boundaryPath, fieldExtent, gridExtent, @@ -251,6 +326,8 @@ export function createFieldPaths( fourStepGrid: fourStepGridPath, yardLines: yardLinesPath, hashMarks: hashMarksPath, + hashGuideLines: hashGuideLinesPath, + sidelineHashMarks: sidelineHashMarksPath, boundary: boundaryPath, }); PATH_CACHE.set(template, paths); @@ -366,6 +443,27 @@ function stepIntervalCoordinates( return Object.freeze(coordinates); } +function spacedInteriorCoordinates( + minimum: number, + maximum: number, + spacing: number, +): readonly number[] { + const coordinates: number[] = []; + for ( + let coordinate = minimum + spacing; + coordinate < maximum - COORDINATE_EPSILON; + coordinate += spacing + ) { + coordinates.push(coordinate); + } + return coordinates; +} + +function isMultipleOfSpacing(distance: number, spacing: number): boolean { + const quotient = distance / spacing; + return Math.abs(quotient - Math.round(quotient)) <= COORDINATE_EPSILON; +} + function freezeExtent(extent: FieldPathExtent): FieldPathExtent { return Object.freeze(extent); } diff --git a/packages/mobile/src/field/render/field-canvas.tsx b/packages/mobile/src/field/render/field-canvas.tsx index bae95c98..11f1080e 100644 --- a/packages/mobile/src/field/render/field-canvas.tsx +++ b/packages/mobile/src/field/render/field-canvas.tsx @@ -53,6 +53,7 @@ export interface FieldCanvasProps { readonly anchors?: readonly FieldAnchorGeometry[]; readonly anchorOverlayOptions?: FieldAnchorOverlayOptions; readonly showPerimeterStepGrid?: boolean; + readonly showAuxiliaryFieldMarks?: boolean; readonly style?: StyleProp; readonly testID?: string; } @@ -72,6 +73,7 @@ export function FieldCanvas({ anchors = EMPTY_FIELD_ANCHORS, anchorOverlayOptions = HIDDEN_FIELD_ANCHOR_OVERLAY, showPerimeterStepGrid = false, + showAuxiliaryFieldMarks = true, style, testID = "field-canvas", }: FieldCanvasProps) { @@ -168,6 +170,7 @@ export function FieldCanvas({ anchors={anchors} anchorOverlayOptions={anchorOverlayOptions} showPerimeterStepGrid={showPerimeterStepGrid} + showAuxiliaryFieldMarks={showAuxiliaryFieldMarks} /> diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx index 905165df..54a99f17 100644 --- a/packages/mobile/src/field/render/field-scene.tsx +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -31,6 +31,7 @@ interface FieldSceneProps { readonly anchors: readonly FieldAnchorGeometry[]; readonly anchorOverlayOptions: FieldAnchorOverlayOptions; readonly showPerimeterStepGrid: boolean; + readonly showAuxiliaryFieldMarks: boolean; } export function FieldScene({ @@ -45,6 +46,7 @@ export function FieldScene({ anchors, anchorOverlayOptions, showPerimeterStepGrid, + showAuxiliaryFieldMarks, }: FieldSceneProps) { const cameraTransform = useDerivedValue(() => [ { translateX: canvasSize.value.width / 2 }, @@ -63,6 +65,7 @@ export function FieldScene({ metersPerPixel={camera.metersPerPixel} palette={palette} showPerimeterStepGrid={showPerimeterStepGrid} + showAuxiliaryFieldMarks={showAuxiliaryFieldMarks} /> ; readonly palette: FieldRenderPalette; readonly showPerimeterStepGrid: boolean; + readonly showAuxiliaryFieldMarks: boolean; } export const FieldStaticLayer = React.memo(function FieldStaticLayer({ @@ -21,6 +23,7 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ metersPerPixel, palette, showPerimeterStepGrid, + showAuxiliaryFieldMarks, }: FieldStaticLayerProps) { const stepGridStroke = useDerivedValue(() => metersPerPixel.value * 0.7); const fourStepStroke = useDerivedValue(() => metersPerPixel.value * 1.1); @@ -85,6 +88,24 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ style="stroke" strokeWidth={fieldLineStroke} /> + {showAuxiliaryFieldMarks ? ( + <> + + + + ) : null} {numberFont ? template.yardNumbers.map((number) => { - const width = numberFont.measureText(number.label).width; + const layout = createYardNumberTextLayout( + numberFont.measureText(number.label), + number.heightMeters, + number.side, + ); return ( - + + + ); }) diff --git a/packages/mobile/src/field/render/index.ts b/packages/mobile/src/field/render/index.ts index d88ce3d0..88968502 100644 --- a/packages/mobile/src/field/render/index.ts +++ b/packages/mobile/src/field/render/index.ts @@ -3,3 +3,4 @@ export * from "./field-render-tokens"; export * from "./field-canvas"; export * from "./page-dial-canvas"; export * from "./field-overlay-types"; +export * from "./yard-number-layout"; diff --git a/packages/mobile/src/field/render/yard-number-layout.ts b/packages/mobile/src/field/render/yard-number-layout.ts new file mode 100644 index 00000000..7088c16e --- /dev/null +++ b/packages/mobile/src/field/render/yard-number-layout.ts @@ -0,0 +1,54 @@ +import type { FieldYardNumber } from "../template"; + +export interface TextVisualBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface YardNumberTextLayout { + /** Baseline origin before the orientation transform is applied. */ + readonly x: number; + readonly y: number; + readonly scaleX: number; + readonly scaleY: number; + readonly visualWidthMeters: number; + readonly visualHeightMeters: number; +} + +/** + * Centers measured glyph bounds at the origin and scales their visual height + * to the schema-defined physical height. Front and back rows face their + * nearest sideline once the field scene's world-to-screen Y reflection runs. + */ +export function createYardNumberTextLayout( + bounds: TextVisualBounds, + targetHeightMeters: number, + side: FieldYardNumber["side"], +): YardNumberTextLayout { + if ( + !Number.isFinite(targetHeightMeters) || + targetHeightMeters <= 0 || + !Number.isFinite(bounds.x) || + !Number.isFinite(bounds.y) || + !Number.isFinite(bounds.width) || + bounds.width < 0 || + !Number.isFinite(bounds.height) || + bounds.height <= 0 + ) { + throw new RangeError( + "Yard-number text bounds and height must be finite and positive.", + ); + } + + const scale = targetHeightMeters / bounds.height; + return Object.freeze({ + x: -bounds.x - bounds.width / 2, + y: -bounds.y - bounds.height / 2, + scaleX: side === "front" ? scale : -scale, + scaleY: side === "front" ? -scale : scale, + visualWidthMeters: bounds.width * scale, + visualHeightMeters: bounds.height * scale, + }); +} diff --git a/packages/mobile/src/field/template.ts b/packages/mobile/src/field/template.ts index 91144bdc..8160388a 100644 --- a/packages/mobile/src/field/template.ts +++ b/packages/mobile/src/field/template.ts @@ -1,5 +1,6 @@ import { getFieldPreset, + type FieldMarkingDefinition, type FieldPresetId, type ResolvedFieldDefinition, } from "@eight2five/drill-schema"; @@ -31,11 +32,12 @@ export interface FieldLine { export interface FieldYardNumber { readonly label: string; - /** Side-relative number printed on the field (10, 20, 30, 40, or 50). */ + /** Side-relative number printed on the field (0, 10, 20, 30, 40, or 50). */ readonly yardLineYards: number; readonly xMeters: number; readonly yMeters: number; readonly side: "front" | "back"; + /** Reference width only; rendering preserves the font's natural aspect ratio. */ readonly widthMeters: number; readonly heightMeters: number; } @@ -53,7 +55,13 @@ export interface StandardFootballFieldDimensions { readonly highSchoolHashFromSidelineFeet: number; /** @deprecated Use hashFromSidelineMeters. */ readonly highSchoolHashFromSidelineMeters: number; + readonly yardNumberCenterFromFrontSidelineFeet: number; + readonly yardNumberCenterFromFrontSidelineMeters: number; + readonly yardNumberCenterFromBackSidelineFeet: number; + readonly yardNumberCenterFromBackSidelineMeters: number; + /** @deprecated Use yardNumberCenterFromFrontSidelineFeet. */ readonly yardNumberInsetFromSidelineFeet: number; + /** @deprecated Use yardNumberCenterFromFrontSidelineMeters. */ readonly yardNumberInsetFromSidelineMeters: number; readonly yardNumberWidthFeet: number; readonly yardNumberHeightFeet: number; @@ -99,12 +107,6 @@ const FIELD_LENGTH_YARDS = 100 as const; const FIELD_WIDTH_YARDS = 160 / 3; const FIELD_LENGTH_METERS = yardsToMeters(FIELD_LENGTH_YARDS); const FIELD_WIDTH_METERS = feetToMeters(160); -const YARD_NUMBER_INSET_FEET = 12; -const YARD_NUMBER_INSET_METERS = feetToMeters(YARD_NUMBER_INSET_FEET); -const YARD_NUMBER_WIDTH_FEET = 4; -const YARD_NUMBER_HEIGHT_FEET = 6; -const YARD_NUMBER_WIDTH_METERS = feetToMeters(YARD_NUMBER_WIDTH_FEET); -const YARD_NUMBER_HEIGHT_METERS = feetToMeters(YARD_NUMBER_HEIGHT_FEET); export const STANDARD_FIELD_LENGTH_YARDS = FIELD_LENGTH_YARDS; export const STANDARD_FIELD_WIDTH_YARDS = FIELD_WIDTH_YARDS; @@ -165,17 +167,20 @@ function yLine( function makeYardNumbers( bounds: StandardFootballFieldTemplate["bounds"], + markings: FieldMarkingDefinition, ): readonly FieldYardNumber[] { const numbers: FieldYardNumber[] = []; - for (const xYards of [-40, -30, -20, -10, 0, 10, 20, 30, 40]) { + for (const xYards of [-50, -40, -30, -20, -10, 0, 10, 20, 30, 40, 50]) { const sideRelativeYards = xYards === 0 ? 50 : 50 - Math.abs(xYards); const label = String(sideRelativeYards); const xMeters = yardsToMeters(xYards); for (const side of ["front", "back"] as const) { const yMeters = side === "front" - ? bounds.minYMeters + YARD_NUMBER_INSET_METERS - : bounds.maxYMeters - YARD_NUMBER_INSET_METERS; + ? bounds.minYMeters + + markings.yardNumbers.centerFromFrontSidelineMeters + : bounds.maxYMeters - + markings.yardNumbers.centerFromBackSidelineMeters; numbers.push( Object.freeze({ label, @@ -183,8 +188,8 @@ function makeYardNumbers( xMeters, yMeters, side, - widthMeters: YARD_NUMBER_WIDTH_METERS, - heightMeters: YARD_NUMBER_HEIGHT_METERS, + widthMeters: markings.yardNumbers.nominalWidthMeters, + heightMeters: markings.yardNumbers.heightMeters, }), ); } @@ -222,6 +227,7 @@ export function createStandardFootballFieldTemplate( const frontHashMeters = findReference(fieldDefinition, "front-hash"); const backHashMeters = findReference(fieldDefinition, "back-hash"); const frontHashFromSidelineMeters = frontHashMeters - bounds.minYMeters; + const markings = fieldDefinition.markings; const prefix = hashPrefix(fieldPreset); const goalLines = Object.freeze([ @@ -267,12 +273,25 @@ export function createStandardFootballFieldTemplate( hashFromSidelineMeters: frontHashFromSidelineMeters, highSchoolHashFromSidelineFeet: metersToFeet(frontHashFromSidelineMeters), highSchoolHashFromSidelineMeters: frontHashFromSidelineMeters, - yardNumberInsetFromSidelineFeet: YARD_NUMBER_INSET_FEET, - yardNumberInsetFromSidelineMeters: YARD_NUMBER_INSET_METERS, - yardNumberWidthFeet: YARD_NUMBER_WIDTH_FEET, - yardNumberHeightFeet: YARD_NUMBER_HEIGHT_FEET, - yardNumberWidthMeters: YARD_NUMBER_WIDTH_METERS, - yardNumberHeightMeters: YARD_NUMBER_HEIGHT_METERS, + yardNumberCenterFromFrontSidelineFeet: metersToFeet( + markings.yardNumbers.centerFromFrontSidelineMeters, + ), + yardNumberCenterFromFrontSidelineMeters: + markings.yardNumbers.centerFromFrontSidelineMeters, + yardNumberCenterFromBackSidelineFeet: metersToFeet( + markings.yardNumbers.centerFromBackSidelineMeters, + ), + yardNumberCenterFromBackSidelineMeters: + markings.yardNumbers.centerFromBackSidelineMeters, + yardNumberInsetFromSidelineFeet: metersToFeet( + markings.yardNumbers.centerFromFrontSidelineMeters, + ), + yardNumberInsetFromSidelineMeters: + markings.yardNumbers.centerFromFrontSidelineMeters, + yardNumberWidthFeet: metersToFeet(markings.yardNumbers.nominalWidthMeters), + yardNumberHeightFeet: metersToFeet(markings.yardNumbers.heightMeters), + yardNumberWidthMeters: markings.yardNumbers.nominalWidthMeters, + yardNumberHeightMeters: markings.yardNumbers.heightMeters, }); const template: StandardFootballFieldTemplate = Object.freeze({ @@ -293,7 +312,7 @@ export function createStandardFootballFieldTemplate( fiveYardLines, allFiveYardLines, yardLines: fiveYardLines, - yardNumbers: makeYardNumbers(bounds), + yardNumbers: makeYardNumbers(bounds, markings), }); TEMPLATE_CACHE.set(fieldPreset, template); return template; From 33960f03699cdf1a76b17e79b7a6c6e07e50e51e Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 12:06:32 -0500 Subject: [PATCH 066/101] feat(drill): preserve imported drill documents --- .../drill/__tests__/drill-import.test.ts | 76 +-- .../mobile/src/features/drill/drill-import.ts | 67 +- .../mobile/src/drill/SqliteDrillRepository.ts | 582 +++++++++++++++++- .../drill/__tests__/sqlite-repository.test.ts | 435 ++++++++++++- packages/mobile/src/drill/index.ts | 2 + packages/mobile/src/drill/types.ts | 18 +- .../storage/__tests__/mobileDatabase.test.ts | 6 +- packages/mobile/src/storage/mobileDatabase.ts | 22 +- 8 files changed, 1046 insertions(+), 162 deletions(-) diff --git a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts index c650f03a..821c1f17 100644 --- a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts +++ b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts @@ -85,9 +85,7 @@ function createRepository() { updatedAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), }; const repository = { - createDrill: jest.fn(async () => created), - createSet: jest.fn(async (input) => ({ id: "set", ...input })), - deleteDrill: jest.fn(async () => undefined), + createImportedDrill: jest.fn(async () => created), } as unknown as DrillRepository; return { created, repository }; } @@ -106,29 +104,10 @@ describe("Eight2Five drill import", () => { importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)), ).resolves.toBe(created); - expect(repository.createDrill).toHaveBeenCalledWith({ - name: "Part 4 Finale", - fieldPreset: "football-nfhs", - createdAt: Date.parse("2026-08-03T18:00:00.000Z"), - updatedAt: Date.parse("2026-08-03T18:00:00.000Z"), + expect(repository.createImportedDrill).toHaveBeenCalledWith({ + sourceDocument: VALID_DOCUMENT, + selectedPerformerEntityId: 42, }); - expect(repository.createSet).toHaveBeenNthCalledWith(1, { - drillId: "drill-1", - number: 1, - kind: "set", - countsFromPrevious: 0, - position: { xSteps: -8, ySteps: 0 }, - }); - expect(repository.createSet).toHaveBeenNthCalledWith(2, { - drillId: "drill-1", - number: 2, - kind: "set", - countsFromPrevious: 16, - measureRange: { start: 12, end: 15 }, - position: { xSteps: 4, ySteps: 32 }, - facingDegrees: 90, - }); - expect(repository.deleteDrill).not.toHaveBeenCalled(); }); test("accepts multi-performer files with props and groups selectable performers by symbol", () => { @@ -151,23 +130,15 @@ describe("Eight2Five drill import", () => { ]); }); - test("imports only the coordinates for the performer selected from a multi-performer file", async () => { + test("passes the selected performer to atomic imported-drill creation", async () => { const { repository } = createRepository(); await importEight2FiveDrillDocument(repository, MULTI_ENTITY_DOCUMENT, 43); - expect(repository.createSet).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - position: { xSteps: 10, ySteps: 12 }, - }), - ); - expect(repository.createSet).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - position: { xSteps: 14, ySteps: 20 }, - }), - ); + expect(repository.createImportedDrill).toHaveBeenCalledWith({ + sourceDocument: MULTI_ENTITY_DOCUMENT, + selectedPerformerEntityId: 43, + }); }); test("requires an explicit performer selection when a file has multiple performers", async () => { @@ -176,10 +147,10 @@ describe("Eight2Five drill import", () => { await expect( importEight2FiveDrillDocument(repository, MULTI_ENTITY_DOCUMENT), ).rejects.toThrow("Select your performer"); - expect(repository.createDrill).not.toHaveBeenCalled(); + expect(repository.createImportedDrill).not.toHaveBeenCalled(); }); - test("allows unsupported path geometry on other entities but rejects it for the selected performer", async () => { + test("accepts polyline and Bézier paths without losing the import", async () => { const otherEntityCurved: DrillDocument = { ...MULTI_ENTITY_DOCUMENT, paths: [ @@ -212,8 +183,11 @@ describe("Eight2Five drill import", () => { const secondRepository = createRepository().repository; await expect( importEight2FiveDrillDocument(secondRepository, selectedCurved, 43), - ).rejects.toThrow("polyline or Bézier"); - expect(secondRepository.createDrill).not.toHaveBeenCalled(); + ).resolves.toBeDefined(); + expect(secondRepository.createImportedDrill).toHaveBeenCalledWith({ + sourceDocument: selectedCurved, + selectedPerformerEntityId: 43, + }); }); test("rejects custom fields and files without any performers", () => { @@ -270,25 +244,19 @@ describe("Eight2Five drill import", () => { ); }); - test("rolls back a partially-created drill if a set insert fails", async () => { + test("propagates atomic import failures without a cleanup fallback", async () => { const repository = { - createDrill: jest.fn(async () => ({ - id: "drill-1", - name: "Part 4 Finale", - fieldPreset: "football-nfhs" as const, - createdAt: 1, - updatedAt: 1, - })), - createSet: jest + createImportedDrill: jest .fn() - .mockResolvedValueOnce({ id: "set-1" }) .mockRejectedValueOnce(new Error("database failed")), - deleteDrill: jest.fn(async () => undefined), } as unknown as DrillRepository; await expect( importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)), ).rejects.toThrow("database failed"); - expect(repository.deleteDrill).toHaveBeenCalledWith("drill-1"); + expect(repository.createImportedDrill).toHaveBeenCalledWith({ + sourceDocument: VALID_DOCUMENT, + selectedPerformerEntityId: 42, + }); }); }); diff --git a/apps/mobile/src/features/drill/drill-import.ts b/apps/mobile/src/features/drill/drill-import.ts index f9177dd5..500683ad 100644 --- a/apps/mobile/src/features/drill/drill-import.ts +++ b/apps/mobile/src/features/drill/drill-import.ts @@ -75,59 +75,12 @@ export async function importEight2FiveDrillDocument( ): Promise { assertMobileDocumentSupport(document); const performer = resolveSelectedPerformer(document, performerEntityId); - assertSelectedPerformerSupport(document, performer); + assertSelectedPerformerPositions(document, performer); - const fieldPreset = document.field.preset; - const positionsBySet = new Map( - document.positions - .filter((position) => position.entityId === performer.id) - .map((position) => [position.setId, position] as const), - ); - const createdAt = Date.parse(document.metadata.createdAt); - - const drill = await repository.createDrill({ - name: document.metadata.title, - fieldPreset, - createdAt, - updatedAt: createdAt, + return await repository.createImportedDrill({ + sourceDocument: document, + selectedPerformerEntityId: performer.id, }); - - try { - for (const set of document.sets) { - const position = positionsBySet.get(set.id); - if (!position) { - throw new Error( - `Drill position ${set.number}${set.suffix ?? ""} is missing ${performer.label}'s coordinate.`, - ); - } - - await repository.createSet({ - drillId: drill.id, - number: set.number, - ...(set.suffix !== undefined ? { suffix: set.suffix } : {}), - kind: set.kind, - countsFromPrevious: set.countsFromPrevious, - ...(set.measureRange ? { measureRange: set.measureRange } : {}), - position: { - xSteps: position.xSteps, - ySteps: position.ySteps, - }, - ...(position.facingDegrees !== undefined - ? { facingDegrees: position.facingDegrees } - : {}), - }); - } - } catch (cause) { - try { - await repository.deleteDrill(drill.id); - } catch { - // Preserve the original import failure. The repository normally cascades - // drill deletion to any sets already inserted. - } - throw cause; - } - - return drill; } function assertMobileDocumentSupport( @@ -169,7 +122,7 @@ function resolveSelectedPerformer( return performer; } -function assertSelectedPerformerSupport( +function assertSelectedPerformerPositions( document: DrillDocument, performer: DrillEntity, ): void { @@ -183,14 +136,4 @@ function assertSelectedPerformerSupport( `Every drill position must include a coordinate for ${performer.label}.`, ); } - - if ( - document.paths?.some( - (path) => path.entityId === performer.id && path.kind !== "straight", - ) - ) { - throw new Error( - `${performer.label} uses polyline or Bézier drill paths, which are not supported in the mobile app yet.`, - ); - } } diff --git a/packages/mobile/src/drill/SqliteDrillRepository.ts b/packages/mobile/src/drill/SqliteDrillRepository.ts index af1c1be1..f938f540 100644 --- a/packages/mobile/src/drill/SqliteDrillRepository.ts +++ b/packages/mobile/src/drill/SqliteDrillRepository.ts @@ -1,6 +1,9 @@ import { formatSetName, isFieldPresetId, + parseDrillDocument, + type DrillDocument, + type DrillMetadata, type DrillGridPoint, type FieldPresetId, type MeasureRange, @@ -26,6 +29,21 @@ export interface CreateDrillInput { readonly createdAt?: number; readonly updatedAt?: number; readonly fieldPreset?: FieldPresetId; + /** Small metadata summary kept in columns for list/detail queries. */ + readonly metadata?: DrillMetadata; +} + +/** + * Atomic imported-drill creation input. The repository creates the drill and + * its selected-performer projection in one SQLite transaction. + */ +export interface CreateImportedDrillInput { + readonly id?: string; + /** Must be the document returned by the validated import boundary. */ + readonly sourceDocument: DrillDocument; + readonly selectedPerformerEntityId: number; + readonly createdAt?: number; + readonly updatedAt?: number; } export interface CreateDrillSetDetails { @@ -37,6 +55,7 @@ export interface CreateDrillSetDetails { readonly measureRange?: MeasureRange; readonly position: DrillGridPoint; readonly facingDegrees?: number; + readonly sourceSetId?: number; } export interface CreateDrillSetInput extends CreateDrillSetDetails { @@ -69,8 +88,12 @@ export interface DrillRepository { listDrills(): Promise; getDrill(id: string): Promise; createDrill(input: CreateDrillInput | string): Promise; + /** Creates an imported drill and its local selected-performer projection atomically. */ + createImportedDrill(input: CreateImportedDrillInput): Promise; renameDrill(id: string, name: string, updatedAt?: number): Promise; deleteDrill(id: string): Promise; + getDrillDocument(drillId: string): Promise; + setSelectedPerformer(drillId: string, entityId: number): Promise; setActiveDrill(id: string | null): Promise; listSets(drillId: string): Promise; @@ -142,7 +165,10 @@ export class SqliteDrillRepository implements DrillRepository { async listDrills(): Promise { const rows = await this.db.getAllAsync( - `SELECT id, name, field_preset, created_at, updated_at + `SELECT id, name, field_preset, created_at, updated_at, + metadata_title, metadata_created_at, metadata_drill_writer, + metadata_ensemble, metadata_description, metadata_lucide_icon, + selected_performer_entity_id FROM ${DRILLS_TABLE} ORDER BY created_at ASC, id ASC`, ); @@ -152,7 +178,10 @@ export class SqliteDrillRepository implements DrillRepository { async getDrill(id: string): Promise { const drillId = assertId(id, "Drill id"); const row = await this.db.getFirstAsync( - `SELECT id, name, field_preset, created_at, updated_at + `SELECT id, name, field_preset, created_at, updated_at, + metadata_title, metadata_created_at, metadata_drill_writer, + metadata_ensemble, metadata_description, metadata_lucide_icon, + selected_performer_entity_id FROM ${DRILLS_TABLE} WHERE id = ?`, [drillId], @@ -160,9 +189,50 @@ export class SqliteDrillRepository implements DrillRepository { return row ? toDrill(row) : undefined; } + /** + * Read and validate the complete imported document. List/detail drill + * queries intentionally do not select or parse this potentially large JSON + * column; callers opt in through this method when they need source data. + */ + async getDrillDocument(id: string): Promise { + const drillId = assertId(id, "Drill id"); + const row = await this.db.getFirstAsync( + `SELECT source_document_json FROM ${DRILLS_TABLE} WHERE id = ?`, + [drillId], + ); + if (!row) return undefined; + const sourceDocumentJson = row.source_document_json; + if (sourceDocumentJson === null || sourceDocumentJson === undefined) { + return undefined; + } + if (typeof sourceDocumentJson !== "string") { + throw new MobileRowError("drill source_document_json is not a string."); + } + try { + return parseDrillDocument(JSON.parse(sourceDocumentJson) as unknown); + } catch (cause) { + throw new MobileRowError( + `The persisted source document for drill ${drillId} is invalid.`, + cause, + ); + } + } + async createDrill(inputOrName: CreateDrillInput | string): Promise { const input: CreateDrillInput = typeof inputOrName === "string" ? { name: inputOrName } : inputOrName; + const importedFields = input as CreateDrillInput & { + readonly sourceDocument?: DrillDocument; + readonly selectedPerformerEntityId?: number | null; + }; + if ( + importedFields.sourceDocument !== undefined || + importedFields.selectedPerformerEntityId !== undefined + ) { + throw invalidInput( + "Imported drills must be created with createImportedDrill so their projection is atomic.", + ); + } const name = assertText(input.name, "Drill name"); const createdAt = assertTimestamp( input.createdAt ?? this.timeFactory(), @@ -177,13 +247,66 @@ export class SqliteDrillRepository implements DrillRepository { if (!isFieldPresetId(fieldPreset)) { throw invalidInput(`Unsupported field preset ${String(fieldPreset)}.`); } + const metadata = normalizeMetadata(input.metadata, name, createdAt); + + await this.insertDrillRow({ + id, + name, + fieldPreset, + createdAt, + updatedAt, + metadata, + }); + return requireValue(await this.getDrill(id), "drill", id); + } - await this.db.runAsync( - `INSERT INTO ${DRILLS_TABLE} - (id, name, field_preset, created_at, updated_at) - VALUES (?, ?, ?, ?, ?)`, - [id, name, fieldPreset, createdAt, updatedAt], + async createImportedDrill(input: CreateImportedDrillInput): Promise { + // The importer validates this document before crossing the repository + // boundary. Keep the repository on the trusted path so validation happens + // once, while persisted reads still validate untrusted SQLite data. + const sourceDocument = input.sourceDocument; + const selectedPerformerEntityId = assertNonNegativeInteger( + input.selectedPerformerEntityId, + "Selected performer entity id", + ); + const projectedSets = projectSelectedPerformerSets( + sourceDocument, + selectedPerformerEntityId, + ); + const id = assertId(input.id ?? this.idFactory(), "Drill id"); + const metadata = sourceDocument.metadata; + const createdAt = assertTimestamp( + input.createdAt ?? parseDocumentTimestamp(sourceDocument), + "Drill createdAt", ); + const updatedAt = assertTimestamp( + input.updatedAt ?? createdAt, + "Drill updatedAt", + ); + const fieldPreset = getPresetFromDocument(sourceDocument); + const sourceDocumentJson = serializeValidatedDrillDocument(sourceDocument); + + await this.db.withTransactionAsync(async () => { + await this.insertDrillRow({ + id, + name: sourceDocument.metadata.title, + fieldPreset, + createdAt, + updatedAt, + metadata, + sourceDocumentJson, + selectedPerformerEntityId, + }); + for (const [ordinal, set] of projectedSets.entries()) { + await this.insertSetRow({ + ...normalizeCreateSet({ drillId: id, ...set }), + id: assertId(this.idFactory(), "Drill set id"), + ordinal, + }); + } + await this.validateSetStructure(id); + }); + return requireValue(await this.getDrill(id), "drill", id); } @@ -199,13 +322,123 @@ export class SqliteDrillRepository implements DrillRepository { "Drill updatedAt", ); await this.requireDrill(id); - await this.db.runAsync( - `UPDATE ${DRILLS_TABLE} SET name = ?, updated_at = ? WHERE id = ?`, - [nextName, nextUpdatedAt, id], - ); + const sourceDocument = await this.getDrillDocument(id); + if (sourceDocument) { + const nextDocument: DrillDocument = { + ...sourceDocument, + metadata: { ...sourceDocument.metadata, title: nextName }, + }; + await this.db.withTransactionAsync(async () => { + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, metadata_title = ?, source_document_json = ?, updated_at = ? + WHERE id = ?`, + [ + nextName, + nextName, + serializeValidatedDrillDocument(nextDocument), + nextUpdatedAt, + id, + ], + ); + }); + } else { + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, metadata_title = ?, updated_at = ? WHERE id = ?`, + [nextName, nextName, nextUpdatedAt, id], + ); + } return requireValue(await this.getDrill(id), "drill", id); } + /** + * Change the selected performer while rebuilding the indexed local set + * projection. The portable source document is never mutated. + */ + async setSelectedPerformer( + drillIdValue: string, + entityIdValue: number, + ): Promise { + const drillId = assertId(drillIdValue, "Drill id"); + const entityId = assertNonNegativeInteger( + entityIdValue, + "Selected performer entity id", + ); + await this.requireDrill(drillId); + const sourceDocument = await this.getDrillDocument(drillId); + if (!sourceDocument) { + throw new DrillRepositoryError( + "INVALID_SELECTION", + "Only imported drills can select a performer.", + ); + } + const projectedSets = projectSelectedPerformerSets( + sourceDocument, + entityId, + ); + + await this.db.withTransactionAsync(async () => { + await this.ensureSettingsRow(); + const settings = await this.db.getFirstAsync<{ + active_drill_id: SqlValue | undefined; + selected_drill_page_id: SqlValue | undefined; + }>( + `SELECT active_drill_id, selected_drill_page_id + FROM ${APP_SETTINGS_TABLE} WHERE singleton_id = ?`, + [1], + ); + const activeDrillId = nullableIdFromSql(settings?.active_drill_id); + const selectedSetId = nullableIdFromSql(settings?.selected_drill_page_id); + const previousSelectedSourceSetId = await this.findSourceSetId( + selectedSetId, + drillId, + ); + + await this.db.runAsync( + `DELETE FROM ${DRILL_SETS_TABLE} WHERE drill_id = ?`, + [drillId], + ); + const localSetIdsBySourceSetId = new Map(); + for (const [ordinal, set] of projectedSets.entries()) { + const localSetId = assertId(this.idFactory(), "Drill set id"); + localSetIdsBySourceSetId.set(set.sourceSetId, localSetId); + await this.insertSetRow({ + ...normalizeCreateSet({ drillId, ...set }), + id: localSetId, + ordinal, + }); + } + + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET selected_performer_entity_id = ? WHERE id = ?`, + [entityId, drillId], + ); + + if (activeDrillId === drillId) { + const sourceSetId = + previousSelectedSourceSetId !== null && + localSetIdsBySourceSetId.has(previousSelectedSourceSetId) + ? previousSelectedSourceSetId + : projectedSets[0]?.sourceSetId; + const nextSelectedSetId = + sourceSetId === undefined + ? null + : (localSetIdsBySourceSetId.get(sourceSetId) ?? null); + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET selected_drill_page_id = ? WHERE singleton_id = ?`, + [nextSelectedSetId, 1], + ); + } + + await this.validateSetStructure(drillId); + }); + + return requireValue(await this.getDrill(drillId), "drill", drillId); + } + async deleteDrill(id: string): Promise { const drillId = assertId(id, "Drill id"); await this.db.runAsync(`DELETE FROM ${DRILLS_TABLE} WHERE id = ?`, [ @@ -227,19 +460,24 @@ export class SqliteDrillRepository implements DrillRepository { const currentActive = nullableIdFromSql(current?.active_drill_id); await this.db.runAsync( `UPDATE ${APP_SETTINGS_TABLE} - SET active_drill_id = ?, - selected_drill_page_id = CASE - WHEN active_drill_id IS ? THEN selected_drill_page_id - ELSE NULL - END + SET active_drill_id = ? WHERE singleton_id = ?`, - [activeDrillId, activeDrillId, 1], + [activeDrillId, 1], ); - if (currentActive !== activeDrillId && activeDrillId === null) { + if (currentActive !== activeDrillId || activeDrillId === null) { + let firstSetId: string | null = null; + if (activeDrillId !== null) { + const firstSet = await this.db.getFirstAsync<{ id: SqlValue }>( + `SELECT id FROM ${DRILL_SETS_TABLE} + WHERE drill_id = ? ORDER BY ordinal ASC, id ASC LIMIT 1`, + [activeDrillId], + ); + firstSetId = nullableIdFromSql(firstSet?.id); + } await this.db.runAsync( `UPDATE ${APP_SETTINGS_TABLE} - SET selected_drill_page_id = NULL WHERE singleton_id = ?`, - [1], + SET selected_drill_page_id = ? WHERE singleton_id = ?`, + [firstSetId, 1], ); } }); @@ -265,6 +503,7 @@ export class SqliteDrillRepository implements DrillRepository { async createSet(input: CreateDrillSetInput): Promise { const normalized = normalizeCreateSet(input); + await this.requireEditableDrill(normalized.drillId); const createdId = assertId( normalized.id ?? this.idFactory(), "Drill set id", @@ -290,6 +529,7 @@ export class SqliteDrillRepository implements DrillRepository { const id = assertId(idValue, "Drill set id"); const current = await this.getSet(id); if (!current) throw setNotFound(id); + await this.requireEditableDrill(current.drillId); const next = normalizeExistingSet(current, changes); if (next.ordinal === 0 && next.countsFromPrevious !== 0) { @@ -306,6 +546,9 @@ export class SqliteDrillRepository implements DrillRepository { async deleteSet(idValue: string): Promise { const id = assertId(idValue, "Drill set id"); + const existing = await this.getSet(id); + if (!existing) return; + await this.requireEditableDrill(existing.drillId); await this.db.withTransactionAsync(async () => { const set = await this.getSet(id); if (!set) return; @@ -341,6 +584,7 @@ export class SqliteDrillRepository implements DrillRepository { ): Promise { const normalized = normalizeCreateSet({ ...details, drillId }); const ordinal = assertOrdinal(ordinalValue, "Set ordinal"); + await this.requireEditableDrill(normalized.drillId); const id = assertId(normalized.id ?? this.idFactory(), "Drill set id"); await this.db.withTransactionAsync(async () => { await this.requireDrill(normalized.drillId); @@ -377,6 +621,7 @@ export class SqliteDrillRepository implements DrillRepository { "A set may appear only once in a reorder operation.", ); } + await this.requireEditableDrill(parentId); await this.db.withTransactionAsync(async () => { const rows = await this.db.getAllAsync<{ id: string }>( @@ -488,6 +733,62 @@ export class SqliteDrillRepository implements DrillRepository { return this.setSelectedDrillSet(id); } + private async insertDrillRow(input: InsertDrillRow): Promise { + await this.db.runAsync( + `INSERT INTO ${DRILLS_TABLE} + (id, name, field_preset, created_at, updated_at, + metadata_title, metadata_created_at, metadata_drill_writer, + metadata_ensemble, metadata_description, metadata_lucide_icon, + source_document_json, selected_performer_entity_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + input.id, + input.name, + input.fieldPreset, + input.createdAt, + input.updatedAt, + input.metadata.title, + input.metadata.createdAt, + input.metadata.drillWriter ?? null, + input.metadata.ensemble ?? null, + input.metadata.description ?? null, + input.metadata.lucideIcon ?? null, + input.sourceDocumentJson ?? null, + input.selectedPerformerEntityId ?? null, + ], + ); + } + + private async findSourceSetId( + selectedSetId: string | null, + drillId: string, + ): Promise { + if (selectedSetId === null) return null; + const row = await this.db.getFirstAsync<{ + drill_id: SqlValue | undefined; + source_set_id: SqlValue | undefined; + }>(`SELECT drill_id, source_set_id FROM ${DRILL_SETS_TABLE} WHERE id = ?`, [ + selectedSetId, + ]); + if (rowTextOrNull(row?.drill_id) !== drillId) return null; + return rowNullableInteger(row?.source_set_id, "source_set_id"); + } + + private async requireEditableDrill(drillId: string): Promise { + await this.requireDrill(drillId); + const row = await this.db.getFirstAsync<{ + source_document_json: SqlValue | undefined; + }>(`SELECT source_document_json FROM ${DRILLS_TABLE} WHERE id = ?`, [ + drillId, + ]); + if ( + row?.source_document_json !== null && + row?.source_document_json !== undefined + ) { + throw importedProjectionMutationError(drillId); + } + } + private async requireDrill(id: string): Promise { const drill = await this.getDrill(id); if (!drill) throw drillNotFound(id); @@ -514,9 +815,9 @@ export class SqliteDrillRepository implements DrillRepository { await this.db.runAsync( `INSERT INTO ${DRILL_SETS_TABLE} (id, drill_id, ordinal, set_number, set_suffix, set_kind, - counts_from_previous, measure_start, measure_end, - x_steps, y_steps, facing_degrees) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + counts_from_previous, measure_start, measure_end, + x_steps, y_steps, facing_degrees, source_set_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ set.id, set.drillId, @@ -530,6 +831,7 @@ export class SqliteDrillRepository implements DrillRepository { set.position.xSteps, set.position.ySteps, set.facingDegrees ?? null, + set.sourceSetId ?? null, ], ); } @@ -537,10 +839,10 @@ export class SqliteDrillRepository implements DrillRepository { private async updateSetRow(set: DrillSet): Promise { await this.db.runAsync( `UPDATE ${DRILL_SETS_TABLE} - SET set_number = ?, set_suffix = ?, set_kind = ?, counts_from_previous = ?, - measure_start = ?, measure_end = ?, x_steps = ?, y_steps = ?, - facing_degrees = ? - WHERE id = ?`, + SET set_number = ?, set_suffix = ?, set_kind = ?, counts_from_previous = ?, + measure_start = ?, measure_end = ?, x_steps = ?, y_steps = ?, + facing_degrees = ?, source_set_id = ? + WHERE id = ?`, [ set.number, set.suffix ?? null, @@ -551,6 +853,7 @@ export class SqliteDrillRepository implements DrillRepository { set.position.xSteps, set.position.ySteps, set.facingDegrees ?? null, + set.sourceSetId ?? null, set.id, ], ); @@ -624,7 +927,8 @@ export class SqliteDrillRepository implements DrillRepository { } const SET_SELECT = `SELECT id, drill_id, ordinal, set_number, set_suffix, set_kind, - counts_from_previous, measure_start, measure_end, x_steps, y_steps, facing_degrees + counts_from_previous, measure_start, measure_end, x_steps, y_steps, + facing_degrees, source_set_id FROM ${DRILL_SETS_TABLE}`; interface NormalizedCreateSet { @@ -637,6 +941,18 @@ interface NormalizedCreateSet { readonly measureRange?: MeasureRange; readonly position: DrillGridPoint; readonly facingDegrees?: number; + readonly sourceSetId?: number; +} + +interface InsertDrillRow { + readonly id: string; + readonly name: string; + readonly fieldPreset: FieldPresetId; + readonly createdAt: number; + readonly updatedAt: number; + readonly metadata: DrillMetadata; + readonly sourceDocumentJson?: string; + readonly selectedPerformerEntityId?: number; } function normalizeCreateSet(input: CreateDrillSetInput): NormalizedCreateSet { @@ -661,9 +977,126 @@ function normalizeCreateSet(input: CreateDrillSetInput): NormalizedCreateSet { ...(input.facingDegrees === undefined ? {} : { facingDegrees: assertFacing(input.facingDegrees) }), + ...(input.sourceSetId === undefined + ? {} + : { + sourceSetId: assertNonNegativeInteger( + input.sourceSetId, + "Source set id", + ), + }), }; } +interface ProjectedSetDetails extends CreateDrillSetDetails { + readonly sourceSetId: number; +} + +function projectSelectedPerformerSets( + document: DrillDocument, + performerEntityId: number, +): readonly ProjectedSetDetails[] { + assertSelectedPerformer(document, performerEntityId); + const positionsBySetId = new Map( + document.positions + .filter((position) => position.entityId === performerEntityId) + .map((position) => [position.setId, position] as const), + ); + return document.sets.map((set) => { + const position = positionsBySetId.get(set.id); + if (!position) { + const performer = document.entities.find( + (entity) => entity.id === performerEntityId, + ); + throw invalidInput( + `Drill position ${set.number}${set.suffix ?? ""} is missing ${performer?.label ?? `entity ${performerEntityId}`}'s coordinate.`, + ); + } + return { + number: set.number, + ...(set.suffix === undefined ? {} : { suffix: set.suffix }), + kind: set.kind, + countsFromPrevious: set.countsFromPrevious, + ...(set.measureRange === undefined + ? {} + : { measureRange: set.measureRange }), + position: { + xSteps: position.xSteps, + ySteps: position.ySteps, + }, + ...(position.facingDegrees === undefined + ? {} + : { facingDegrees: position.facingDegrees }), + sourceSetId: set.id, + }; + }); +} + +function getPresetFromDocument(document: DrillDocument): FieldPresetId { + if (document.field.type !== "preset") { + throw invalidInput( + "Custom field definitions are not supported in the mobile app yet.", + ); + } + return document.field.preset; +} + +function parseDocumentTimestamp(document: DrillDocument): number { + const timestamp = Date.parse(document.metadata.createdAt); + if (!Number.isFinite(timestamp)) { + throw invalidInput("Drill metadata createdAt must be a valid date."); + } + return timestamp; +} + +/** The caller supplies a document already validated at the import boundary. */ +function serializeValidatedDrillDocument(document: DrillDocument): string { + return `${JSON.stringify(document, null, 2)}\n`; +} + +function normalizeMetadata( + metadata: DrillMetadata | undefined, + name: string, + createdAt: number, +): DrillMetadata { + const title = assertText(metadata?.title ?? name, "Drill metadata title"); + const metadataCreatedAt = assertText( + metadata?.createdAt ?? timestampToIso(createdAt), + "Drill metadata createdAt", + ); + return { + title, + createdAt: metadataCreatedAt, + ...(metadata?.drillWriter === undefined + ? {} + : { drillWriter: metadata.drillWriter }), + ...(metadata?.ensemble === undefined + ? {} + : { ensemble: metadata.ensemble }), + ...(metadata?.description === undefined + ? {} + : { description: metadata.description }), + ...(metadata?.lucideIcon === undefined + ? {} + : { lucideIcon: metadata.lucideIcon }), + }; +} + +function assertSelectedPerformer( + document: DrillDocument, + entityId: number, +): void { + const entity = document.entities.find( + (candidate) => candidate.id === entityId, + ); + if (!entity || entity.type !== "performer") { + throw new DrillRepositoryError( + "INVALID_SELECTION", + `Entity ${entityId} is not a performer in this drill document.`, + ); + } +} + function normalizeExistingSet( current: DrillSet, changes: UpdateDrillSetInput, @@ -808,12 +1241,56 @@ function toDrill(row: Row): Drill { if (!isFieldPresetId(fieldPreset)) { throw new MobileRowError(`Unsupported mobile field preset ${fieldPreset}.`); } + const name = rowText(row.name, "drill name"); + const createdAt = rowNumber(row.created_at, "drill created_at"); + const metadataCreatedAt = + typeof row.metadata_created_at === "string" && + row.metadata_created_at.trim().length > 0 + ? row.metadata_created_at + : timestampToIso(createdAt); + const metadataTitle = + typeof row.metadata_title === "string" && + row.metadata_title.trim().length > 0 + ? row.metadata_title + : name; + const selectedPerformerEntityId = rowNullableInteger( + row.selected_performer_entity_id, + "drill selected_performer_entity_id", + ); + const drillWriter = rowOptionalText( + row.metadata_drill_writer, + "drill metadata writer", + ); + const ensemble = rowOptionalText( + row.metadata_ensemble, + "drill metadata ensemble", + ); + const description = rowOptionalText( + row.metadata_description, + "drill metadata description", + ); + const lucideIcon = rowOptionalText( + row.metadata_lucide_icon, + "drill metadata lucide icon", + ); + const metadata: DrillMetadata = { + title: metadataTitle, + createdAt: metadataCreatedAt, + ...(drillWriter === undefined ? {} : { drillWriter }), + ...(ensemble === undefined ? {} : { ensemble }), + ...(description === undefined ? {} : { description }), + ...(lucideIcon === undefined ? {} : { lucideIcon }), + }; return { id: rowText(row.id, "drill id"), - name: rowText(row.name, "drill name"), + name, fieldPreset, - createdAt: rowNumber(row.created_at, "drill created_at"), + createdAt, updatedAt: rowNumber(row.updated_at, "drill updated_at"), + metadata, + ...(selectedPerformerEntityId === null + ? {} + : { selectedPerformerEntityId }), }; } @@ -827,6 +1304,7 @@ function toSet(row: Row): DrillSet { const measureStart = rowNullableNumber(row.measure_start, "measure_start"); const measureEnd = rowNullableNumber(row.measure_end, "measure_end"); const facingDegrees = rowNullableNumber(row.facing_degrees, "facing_degrees"); + const sourceSetId = rowNullableInteger(row.source_set_id, "source_set_id"); return { id: rowText(row.id, "drill set id"), drillId: rowText(row.drill_id, "drill set drill_id"), @@ -846,6 +1324,7 @@ function toSet(row: Row): DrillSet { ySteps: rowNumber(row.y_steps, "drill set y_steps"), }, ...(facingDegrees === null ? {} : { facingDegrees }), + ...(sourceSetId === null ? {} : { sourceSetId }), }; } @@ -855,6 +1334,12 @@ function rowText(value: SqlValue | undefined, name: string): string { return value; } +function rowTextOrNull(value: SqlValue | undefined): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + function rowNumber(value: SqlValue | undefined, name: string): number { const number = typeof value === "number" @@ -881,10 +1366,37 @@ function rowNullableNumber( return rowNumber(value, name); } +function timestampToIso(value: number): string { + try { + return new Date(value).toISOString(); + } catch { + return new Date(0).toISOString(); + } +} + +function rowNullableInteger( + value: SqlValue | undefined, + name: string, +): number | null { + if (value === null || value === undefined) return null; + return rowInteger(value, name); +} + +function rowOptionalText( + value: SqlValue | undefined, + name: string, +): string | undefined { + if (value === null || value === undefined) return undefined; + return rowText(value, name); +} + class MobileRowError extends Error { - constructor(message: string) { + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { super(message); this.name = "MobileRowError"; + if (cause !== undefined) this.cause = cause; } } @@ -897,6 +1409,14 @@ function invalidInput(message: string): DrillRepositoryError { return new DrillRepositoryError("INVALID_INPUT", message); } +function importedProjectionMutationError( + drillId: string, +): DrillRepositoryError { + return invalidInput( + `Imported drill ${drillId} sets are an authoritative source projection and cannot be edited.`, + ); +} + function drillNotFound(id: string): DrillRepositoryError { return new DrillRepositoryError( "DRILL_NOT_FOUND", diff --git a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts index 54edd69f..91384985 100644 --- a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts +++ b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts @@ -1,6 +1,75 @@ -import { FIELD_PRESET_IDS, type FieldPresetId } from "@eight2five/drill-schema"; +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + FIELD_PRESET_IDS, + type DrillDocument, + type FieldPresetId, +} from "@eight2five/drill-schema"; import type { SQLiteDatabase } from "expo-sqlite"; -import { SqliteDrillRepository } from "../SqliteDrillRepository"; +import { + SqliteDrillRepository, + type CreateDrillInput, +} from "../SqliteDrillRepository"; + +const IMPORTED_DOCUMENT: DrillDocument = { + schema: DRILL_SCHEMA_URL, + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: "Imported Finale", + createdAt: "2026-08-03T18:00:00.000Z", + drillWriter: "A. Writer", + ensemble: "The Ensemble", + description: "Full document round trip", + lucideIcon: "music-2", + }, + field: { type: "preset", preset: "football-nfhs" }, + entityRules: { + bySymbol: { B: { appearance: { color: "#E53935" } } }, + }, + entities: [ + { id: 10, type: "performer", symbol: "B", label: "B1" }, + { id: 11, type: "performer", symbol: "B", label: "B2" }, + { id: 99, type: "prop", symbol: "P", label: "Flag" }, + ], + sets: [ + { id: 0, number: 1, kind: "set", countsFromPrevious: 0 }, + { id: 1, number: 2, kind: "set", countsFromPrevious: 8 }, + ], + positions: [ + { entityId: 10, setId: 0, xSteps: 0, ySteps: 0 }, + { entityId: 10, setId: 1, xSteps: 8, ySteps: 0 }, + { entityId: 11, setId: 0, xSteps: 0, ySteps: 8 }, + { entityId: 11, setId: 1, xSteps: 8, ySteps: 8 }, + { entityId: 99, setId: 0, xSteps: 4, ySteps: 4 }, + { entityId: 99, setId: 1, xSteps: 12, ySteps: 4 }, + ], + paths: [ + { + entityId: 10, + fromSetId: 0, + toSetId: 1, + kind: "polyline", + waypoints: [{ xSteps: 4, ySteps: 3 }], + }, + { + entityId: 11, + fromSetId: 0, + toSetId: 1, + kind: "bezier", + controlPoints: [ + { xSteps: 2, ySteps: 10 }, + { xSteps: 6, ySteps: 10 }, + ], + }, + ], + provenance: { + source: { kind: "coordinate-sheet", fileName: "finale.pdf" }, + importer: { name: "test", version: "1" }, + importedAt: "2026-08-03T18:01:00.000Z", + references: [{ target: { type: "entity", entityId: 10 }, page: 1 }], + }, + extensions: { custom: { retained: true } }, +}; describe("SqliteDrillRepository", () => { test("uses stable factories and deterministic drill/set ordering", async () => { @@ -81,6 +150,278 @@ describe("SqliteDrillRepository", () => { }, ); + test("round-trips the full imported document and summary metadata", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["full-0", "full-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + + expect(await repository.getDrillDocument(drill.id)).toEqual( + IMPORTED_DOCUMENT, + ); + expect(fake.drills.get(drill.id)?.source_document_json).toBe( + `${JSON.stringify(IMPORTED_DOCUMENT, null, 2)}\n`, + ); + expect(drill).toMatchObject({ + name: "Imported Finale", + selectedPerformerEntityId: 10, + metadata: IMPORTED_DOCUMENT.metadata, + }); + expect(await repository.listDrills()).toEqual([ + expect.objectContaining({ + id: "imported", + metadata: IMPORTED_DOCUMENT.metadata, + }), + ]); + + fake.drills.get(drill.id)!.source_document_json = "not-json"; + await expect(repository.listDrills()).resolves.toHaveLength(1); + await expect(repository.getDrillDocument(drill.id)).rejects.toThrow( + "source document", + ); + }); + + test("rejects imported fields passed through manual drill creation", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => "manual", + timeFactory: () => 1, + }); + + await expect( + repository.createDrill({ + name: IMPORTED_DOCUMENT.metadata.title, + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + } as unknown as CreateDrillInput), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + expect(fake.drills.size).toBe(0); + }); + + test("maps local projected sets to source set ids and reprojections preserve source data", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["local-0", "local-1", "reprojected-0", "reprojected-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + + expect( + (await repository.listSets(drill.id)).map((set) => [ + set.id, + set.sourceSetId, + ]), + ).toEqual([ + ["local-0", 0], + ["local-1", 1], + ]); + await repository.setActiveDrill(drill.id); + await repository.setSelectedDrillSet("local-1"); + + await repository.setSelectedPerformer(drill.id, 11); + + expect(await repository.getDrillDocument(drill.id)).toEqual( + IMPORTED_DOCUMENT, + ); + expect(await repository.getDrill(drill.id)).toMatchObject({ + selectedPerformerEntityId: 11, + }); + expect( + (await repository.listSets(drill.id)).map((set) => ({ + id: set.id, + sourceSetId: set.sourceSetId, + position: set.position, + })), + ).toEqual([ + { + id: "reprojected-0", + sourceSetId: 0, + position: { xSteps: 0, ySteps: 8 }, + }, + { + id: "reprojected-1", + sourceSetId: 1, + position: { xSteps: 8, ySteps: 8 }, + }, + ]); + expect(fake.settings.selected_drill_page_id).toBe("reprojected-1"); + }); + + test("rejects all set mutations for imported projections, including page aliases", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + idFactory: (() => { + const ids = ["local-0", "local-1"]; + return () => ids.shift()!; + })(), + timeFactory: () => 1, + }); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + const beforeSets = await repository.listSets(drill.id); + + const expectRejected = async (operation: Promise) => { + await expect(operation).rejects.toMatchObject({ code: "INVALID_INPUT" }); + }; + await expectRejected( + repository.createSet({ + drillId: drill.id, + number: 3, + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected( + repository.updateSet(beforeSets[0].id, { + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected(repository.deleteSet(beforeSets[0].id)); + await expectRejected( + repository.insertSet(drill.id, 1, { + number: 1, + suffix: "A", + kind: "subset", + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected( + repository.reorderSets( + drill.id, + beforeSets.map((set) => set.id).reverse(), + ), + ); + + await expectRejected( + repository.createPage({ + drillId: drill.id, + number: 3, + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected( + repository.updatePage(beforeSets[0].id, { + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected(repository.deletePage(beforeSets[0].id)); + await expectRejected( + repository.insertPage(drill.id, 1, { + number: 1, + suffix: "A", + kind: "subset", + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected( + repository.reorderPages( + drill.id, + beforeSets.map((set) => set.id).reverse(), + ), + ); + + expect(await repository.listSets(drill.id)).toEqual(beforeSets); + expect(await repository.getDrillDocument(drill.id)).toEqual( + IMPORTED_DOCUMENT, + ); + }); + + test("rejects an invalid performer entity without changing the projection", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["local-0", "local-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + const before = await repository.listSets(drill.id); + + await expect( + repository.setSelectedPerformer(drill.id, 99), + ).rejects.toMatchObject({ + code: "INVALID_SELECTION", + }); + expect(await repository.listSets(drill.id)).toEqual(before); + expect( + (await repository.getDrill(drill.id))?.selectedPerformerEntityId, + ).toBe(10); + }); + + test("rolls back the drill and projection when an imported set insert fails", async () => { + const fake = new DrillFakeDatabase(); + fake.failOnSetInsert = true; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => "set-that-fails", + timeFactory: () => 1, + }); + + await expect( + repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }), + ).rejects.toThrow("set insert failed"); + expect(fake.drills.size).toBe(0); + expect(fake.sets.size).toBe(0); + }); + + test("selects the first set when activating a different drill and clears it on deactivation", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill-a", "drill-b", "set-a-0", "set-b-0", "set-b-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drillA = await repository.createDrill("A"); + const drillB = await repository.createDrill("B"); + const setA = await repository.createSet({ + drillId: drillA.id, + number: 1, + position: { xSteps: 0, ySteps: 0 }, + }); + const setB = await repository.createSet({ + drillId: drillB.id, + number: 1, + position: { xSteps: 1, ySteps: 1 }, + }); + await repository.createSet({ + drillId: drillB.id, + number: 2, + countsFromPrevious: 8, + position: { xSteps: 2, ySteps: 2 }, + }); + + await repository.setActiveDrill(drillA.id); + expect(fake.settings.selected_drill_page_id).toBe(setA.id); + await repository.setActiveDrill(drillB.id); + expect(fake.settings.selected_drill_page_id).toBe(setB.id); + await repository.setActiveDrill(null); + expect(fake.settings.active_drill_id).toBeNull(); + expect(fake.settings.selected_drill_page_id).toBeNull(); + await repository.setActiveDrill(drillA.id); + expect(fake.settings.selected_drill_page_id).toBe(setA.id); + }); + test("inserts, reorders, updates, and deletes sets through transactions", async () => { const fake = new DrillFakeDatabase(); const ids = ["drill", "set-a", "set-b", "set-inserted"]; @@ -260,6 +601,14 @@ type FakeDrillRow = { field_preset: FieldPresetId; created_at: number; updated_at: number; + metadata_title?: string; + metadata_created_at?: string; + metadata_drill_writer?: string | null; + metadata_ensemble?: string | null; + metadata_description?: string | null; + metadata_lucide_icon?: string | null; + source_document_json?: string | null; + selected_performer_entity_id?: number | null; }; type FakeSetRow = { @@ -275,11 +624,13 @@ type FakeSetRow = { x_steps: number; y_steps: number; facing_degrees: number | null; + source_set_id: number | null; }; class DrillFakeDatabase { readonly drills = new Map(); readonly sets = new Map(); + failOnSetInsert = false; readonly settings = { drill_features_enabled: 1, drill_terminology: "sets", @@ -394,12 +745,34 @@ class DrillFakeDatabase { private async run(sql: string, params: unknown[]): Promise { if (sql.includes("INSERT INTO drills")) { - const [id, name, fieldPreset, createdAt, updatedAt] = params as [ + const [ + id, + name, + fieldPreset, + createdAt, + updatedAt, + metadataTitle, + metadataCreatedAt, + metadataWriter, + metadataEnsemble, + metadataDescription, + metadataLucideIcon, + sourceDocumentJson, + selectedPerformerEntityId, + ] = params as [ string, string, FieldPresetId, number, number, + string, + string, + string | null, + string | null, + string | null, + string | null, + string | null, + number | null, ]; this.drills.set(id, { id, @@ -407,8 +780,17 @@ class DrillFakeDatabase { field_preset: fieldPreset, created_at: createdAt, updated_at: updatedAt, + metadata_title: metadataTitle, + metadata_created_at: metadataCreatedAt, + metadata_drill_writer: metadataWriter, + metadata_ensemble: metadataEnsemble, + metadata_description: metadataDescription, + metadata_lucide_icon: metadataLucideIcon, + source_document_json: sourceDocumentJson, + selected_performer_entity_id: selectedPerformerEntityId, }); } else if (sql.includes("INSERT INTO drill_sets")) { + if (this.failOnSetInsert) throw new Error("set insert failed"); const [ id, drillId, @@ -422,6 +804,7 @@ class DrillFakeDatabase { xSteps, ySteps, facingDegrees, + sourceSetId, ] = params as [ string, string, @@ -435,6 +818,7 @@ class DrillFakeDatabase { number, number, number | null, + number | null, ]; this.sets.set(id, { id, @@ -449,6 +833,7 @@ class DrillFakeDatabase { x_steps: xSteps, y_steps: ySteps, facing_degrees: facingDegrees, + source_set_id: sourceSetId, }); } else if (sql.includes("INSERT OR IGNORE INTO app_settings")) { // Singleton already exists in this fake. @@ -468,15 +853,46 @@ class DrillFakeDatabase { this.settings.selected_drill_page_id = null; } } else if (sql.includes("DELETE FROM drill_sets")) { - const id = String(params[0]); - this.sets.delete(id); - if (this.settings.selected_drill_page_id === id) { - this.settings.selected_drill_page_id = null; + if (sql.includes("WHERE drill_id = ?")) { + const drillId = String(params[0]); + for (const [setId, set] of this.sets) { + if (set.drill_id === drillId) { + this.sets.delete(setId); + if (this.settings.selected_drill_page_id === setId) { + this.settings.selected_drill_page_id = null; + } + } + } + } else { + const id = String(params[0]); + this.sets.delete(id); + if (this.settings.selected_drill_page_id === id) { + this.settings.selected_drill_page_id = null; + } + } + } else if ( + sql.includes("UPDATE drills") && + sql.includes("selected_performer_entity_id") + ) { + const [selectedPerformerEntityId, id] = params as [number, string]; + const row = this.drills.get(id); + if (row) { + this.drills.set(id, { + ...row, + selected_performer_entity_id: selectedPerformerEntityId, + }); } } else if (sql.includes("UPDATE drills")) { const [name, updatedAt, id] = params as [string, number, string]; const row = this.drills.get(id); - if (row) this.drills.set(id, { ...row, name, updated_at: updatedAt }); + if (row) { + this.drills.set(id, { + ...row, + name, + metadata_title: name, + updated_at: updatedAt, + }); + } } else if (sql.includes("UPDATE app_settings")) { this.updateSettings(sql, params); } else if (sql.includes("UPDATE drill_sets")) { @@ -568,6 +984,7 @@ class DrillFakeDatabase { xSteps, ySteps, facingDegrees, + sourceSetId, id, ] = params as [ number, @@ -579,6 +996,7 @@ class DrillFakeDatabase { number, number, number | null, + number | null, string, ]; const set = this.sets.get(id); @@ -593,6 +1011,7 @@ class DrillFakeDatabase { x_steps: xSteps, y_steps: ySteps, facing_degrees: facingDegrees, + source_set_id: sourceSetId, }); } } diff --git a/packages/mobile/src/drill/index.ts b/packages/mobile/src/drill/index.ts index 71100a1d..64f6ce09 100644 --- a/packages/mobile/src/drill/index.ts +++ b/packages/mobile/src/drill/index.ts @@ -1,6 +1,8 @@ export { formatSetName, + type DrillDocument, type DrillGridPoint, + type DrillMetadata, type MeasureRange, type SetKind, } from "@eight2five/drill-schema"; diff --git a/packages/mobile/src/drill/types.ts b/packages/mobile/src/drill/types.ts index 9c101051..d3c76e37 100644 --- a/packages/mobile/src/drill/types.ts +++ b/packages/mobile/src/drill/types.ts @@ -1,13 +1,16 @@ import type { DrillGridPoint, + DrillDocument, + DrillMetadata, MeasureRange, SetKind, FieldPresetId, } from "@eight2five/drill-schema"; /** - * App-local drill metadata. The portable drill document owns richer metadata; - * SQLite keeps only the fields needed by the current mobile MVP. + * The metadata columns stored with a drill are a small, query-friendly summary + * of the portable document metadata. The complete metadata (and the rest of + * the document) is available through DrillRepository.getDrillDocument. */ export interface Drill { readonly id: string; @@ -15,15 +18,18 @@ export interface Drill { readonly createdAt: number; readonly updatedAt: number; readonly fieldPreset: FieldPresetId; + readonly metadata?: DrillMetadata; + readonly selectedPerformerEntityId?: number; } /** - * One ordered target set for the single-performer mobile MVP. + * One ordered target set for the selected performer projection. * * `id` is an opaque SQLite row identifier. It intentionally differs from the * portable schema's zero-based set id: imports map portable set order to local * rows, while mobile editing can insert/reorder rows without exposing storage - * identity in drill files. + * identity in drill files. Imported rows retain the portable source set id so + * the projection can be rebuilt without changing the authoritative document. */ export interface DrillSet { readonly id: string; @@ -36,7 +42,11 @@ export interface DrillSet { readonly measureRange?: MeasureRange; readonly position: DrillGridPoint; readonly facingDegrees?: number; + readonly sourceSetId?: number; } +/** A validated portable document retained for an imported drill. */ +export type SourceDrillDocument = DrillDocument; + /** @deprecated Use DrillSet. Kept temporarily for source compatibility. */ export type DrillPage = DrillSet; diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index 015cf8a1..8a366367 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -15,7 +15,7 @@ describe("mobile app SQLite schema preparation", () => { const sql = executed.join("\n"); expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); - expect(MOBILE_SCHEMA_VERSION).toBe(4); + expect(MOBILE_SCHEMA_VERSION).toBe(5); expect(sql).toContain("PRAGMA journal_mode = WAL"); expect(sql).toContain("PRAGMA foreign_keys = OFF"); expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); @@ -29,6 +29,10 @@ describe("mobile app SQLite schema preparation", () => { expect(sql).toContain("set_number INTEGER NOT NULL"); expect(sql).toContain("x_steps REAL NOT NULL"); expect(sql).not.toContain("x_meters REAL"); + expect(sql).toContain("metadata_title TEXT NOT NULL"); + expect(sql).toContain("source_document_json TEXT"); + expect(sql).toContain("selected_performer_entity_id INTEGER"); + expect(sql).toContain("source_set_id INTEGER"); expect(sql).not.toContain("y_meters REAL"); expect(sql).toContain("CREATE TABLE app_settings"); expect(sql).toContain("appearance_mode TEXT NOT NULL DEFAULT 'system'"); diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index 6e3e0251..49959e4f 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -10,7 +10,7 @@ export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; * stable, a version mismatch intentionally rebuilds this disposable database * rather than carrying migration code for development-only layouts. */ -export const MOBILE_SCHEMA_VERSION = 4; +export const MOBILE_SCHEMA_VERSION = 5; export const DRILLS_TABLE = "drills"; export const DRILL_SETS_TABLE = "drill_sets"; @@ -84,7 +84,20 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { field_preset TEXT NOT NULL DEFAULT 'football-nfhs' CHECK (field_preset IN (${FIELD_PRESET_SQL_LIST})), created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + metadata_title TEXT NOT NULL CHECK (length(trim(metadata_title)) > 0), + metadata_created_at TEXT NOT NULL, + metadata_drill_writer TEXT, + metadata_ensemble TEXT, + metadata_description TEXT, + metadata_lucide_icon TEXT, + source_document_json TEXT, + selected_performer_entity_id INTEGER + CHECK ( + selected_performer_entity_id IS NULL OR + (selected_performer_entity_id >= 0 AND + selected_performer_entity_id = CAST(selected_performer_entity_id AS INTEGER)) + ) ); CREATE INDEX idx_drills_created_at @@ -110,6 +123,8 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { y_steps REAL NOT NULL CHECK (y_steps = y_steps), facing_degrees REAL CHECK (facing_degrees IS NULL OR (facing_degrees >= 0 AND facing_degrees < 360)), + source_set_id INTEGER + CHECK (source_set_id IS NULL OR (source_set_id >= 0 AND source_set_id = CAST(source_set_id AS INTEGER))), CHECK ( (set_kind = 'set' AND set_suffix IS NULL) OR (set_kind = 'subset' AND set_suffix IS NOT NULL) @@ -126,6 +141,9 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { CREATE INDEX idx_drill_sets_drill ON ${DRILL_SETS_TABLE}(drill_id, ordinal, id); + CREATE INDEX idx_drill_sets_source + ON ${DRILL_SETS_TABLE}(drill_id, source_set_id); + CREATE TABLE ${APP_SETTINGS_TABLE} ( singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), appearance_mode TEXT NOT NULL DEFAULT 'system' From 842b640f921efbd3c8fc9924f0132c4968b7374e Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 12:33:25 -0500 Subject: [PATCH 067/101] refactor(drill): make drill tab upload and selection only --- .../app/(tabs)/drill/[drillId]/index.tsx | 8 - .../(tabs)/drill/[drillId]/page/[pageId].tsx | 21 -- apps/mobile/app/(tabs)/drill/_layout.tsx | 7 - apps/mobile/app/(tabs)/drill/new.tsx | 5 - apps/mobile/app/(tabs)/drill/upload.tsx | 5 - .../drill/__tests__/drill-icons.test.ts | 16 + .../drill/__tests__/drill-import.test.ts | 41 +++ .../drill/__tests__/drill-management.test.ts | 53 +-- .../drill/__tests__/page-form.test.ts | 67 +--- .../drill/__tests__/page-ordering.test.ts | 164 ---------- .../components/destructive-drill-actions.tsx | 73 ----- .../drill/components/drill-empty-state.tsx | 4 +- .../drill/components/drill-list-item.tsx | 189 +++++++---- .../drill/components/drill-name-dialog.tsx | 51 --- .../drill/components/drill-name-form.tsx | 95 ------ .../drill/components/drill-page-actions.tsx | 97 ------ .../drill/components/drill-page-list-item.tsx | 171 ---------- .../components/drill-properties-dialog.tsx | 306 ++++++++++++++++++ .../components/performer-selection-dialog.tsx | 22 +- .../drill/components/transition-summary.tsx | 60 ---- .../features/drill/drill-editor-screen.tsx | 269 --------------- apps/mobile/src/features/drill/drill-icons.ts | 48 +++ .../mobile/src/features/drill/drill-import.ts | 46 +++ .../src/features/drill/drill-list-screen.tsx | 177 +++++----- .../src/features/drill/drill-management.ts | 48 ++- .../features/drill/drill-upload-screen.tsx | 188 ----------- .../src/features/drill/page-editor-screen.tsx | 104 ------ .../src/features/drill/page-management.ts | 111 ------- .../drill/use-drill-editor-controller.ts | 224 ------------- .../drill/use-drill-list-controller.ts | 287 +++++++++++++++- .../drill/use-page-editor-controller.ts | 142 -------- .../mobile/src/drill/SqliteDrillRepository.ts | 128 ++++++++ .../drill/__tests__/sqlite-repository.test.ts | 101 ++++++ 33 files changed, 1240 insertions(+), 2088 deletions(-) delete mode 100644 apps/mobile/app/(tabs)/drill/[drillId]/index.tsx delete mode 100644 apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx delete mode 100644 apps/mobile/app/(tabs)/drill/new.tsx delete mode 100644 apps/mobile/app/(tabs)/drill/upload.tsx create mode 100644 apps/mobile/src/features/drill/__tests__/drill-icons.test.ts delete mode 100644 apps/mobile/src/features/drill/__tests__/page-ordering.test.ts delete mode 100644 apps/mobile/src/features/drill/components/destructive-drill-actions.tsx delete mode 100644 apps/mobile/src/features/drill/components/drill-name-dialog.tsx delete mode 100644 apps/mobile/src/features/drill/components/drill-name-form.tsx delete mode 100644 apps/mobile/src/features/drill/components/drill-page-actions.tsx delete mode 100644 apps/mobile/src/features/drill/components/drill-page-list-item.tsx create mode 100644 apps/mobile/src/features/drill/components/drill-properties-dialog.tsx delete mode 100644 apps/mobile/src/features/drill/components/transition-summary.tsx delete mode 100644 apps/mobile/src/features/drill/drill-editor-screen.tsx create mode 100644 apps/mobile/src/features/drill/drill-icons.ts delete mode 100644 apps/mobile/src/features/drill/drill-upload-screen.tsx delete mode 100644 apps/mobile/src/features/drill/page-editor-screen.tsx delete mode 100644 apps/mobile/src/features/drill/page-management.ts delete mode 100644 apps/mobile/src/features/drill/use-drill-editor-controller.ts delete mode 100644 apps/mobile/src/features/drill/use-page-editor-controller.ts diff --git a/apps/mobile/app/(tabs)/drill/[drillId]/index.tsx b/apps/mobile/app/(tabs)/drill/[drillId]/index.tsx deleted file mode 100644 index 9172484e..00000000 --- a/apps/mobile/app/(tabs)/drill/[drillId]/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { useLocalSearchParams } from "expo-router"; - -import { DrillEditorScreen } from "../../../../src/features/drill/drill-editor-screen"; - -export default function ExistingDrillRoute() { - const { drillId } = useLocalSearchParams<{ drillId: string }>(); - return ; -} diff --git a/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx b/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx deleted file mode 100644 index 301eaafd..00000000 --- a/apps/mobile/app/(tabs)/drill/[drillId]/page/[pageId].tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useLocalSearchParams } from "expo-router"; - -import { PageEditorScreen } from "../../../../../src/features/drill/page-editor-screen"; -import { normalizePagePlacement } from "../../../../../src/features/drill/page-management"; - -export default function DrillPageRoute() { - const { drillId, pageId, placement, relativePageId } = useLocalSearchParams<{ - drillId: string; - pageId: string; - placement?: "append" | "before" | "after"; - relativePageId?: string; - }>(); - return ( - - ); -} diff --git a/apps/mobile/app/(tabs)/drill/_layout.tsx b/apps/mobile/app/(tabs)/drill/_layout.tsx index aed2a862..c1afa52e 100644 --- a/apps/mobile/app/(tabs)/drill/_layout.tsx +++ b/apps/mobile/app/(tabs)/drill/_layout.tsx @@ -29,13 +29,6 @@ export default function DrillLayout() { }} > - - - - ); } diff --git a/apps/mobile/app/(tabs)/drill/new.tsx b/apps/mobile/app/(tabs)/drill/new.tsx deleted file mode 100644 index a5b333f5..00000000 --- a/apps/mobile/app/(tabs)/drill/new.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Redirect } from "expo-router"; - -export default function LegacyNewDrillRoute() { - return ; -} diff --git a/apps/mobile/app/(tabs)/drill/upload.tsx b/apps/mobile/app/(tabs)/drill/upload.tsx deleted file mode 100644 index 19b2ba8d..00000000 --- a/apps/mobile/app/(tabs)/drill/upload.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { DrillUploadScreen } from "../../../src/features/drill/drill-upload-screen"; - -export default function UploadDrillRoute() { - return ; -} diff --git a/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts b/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts new file mode 100644 index 00000000..14e0e837 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts @@ -0,0 +1,16 @@ +import { + DRILL_ICON_REGISTRY, + FALLBACK_DRILL_ICON, + isSupportedDrillIcon, + resolveDrillIcon, +} from "../drill-icons"; + +describe("drill card icon registry", () => { + test("resolves only controlled icon names and falls back for unknown names", () => { + expect(resolveDrillIcon("music-2")).toBe(DRILL_ICON_REGISTRY["music-2"]); + expect(resolveDrillIcon("made-up-icon")).toBe(FALLBACK_DRILL_ICON); + expect(resolveDrillIcon(undefined)).toBe(FALLBACK_DRILL_ICON); + expect(isSupportedDrillIcon("sparkles")).toBe(true); + expect(isSupportedDrillIcon("made-up-icon")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts index 821c1f17..305beb48 100644 --- a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts +++ b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts @@ -11,6 +11,7 @@ import { importEight2FiveDrillDocument, importEight2FiveDrillJson, isEight2FiveDrillFileName, + parseDrillPickerResult, parseImportableDrillJson, } from "../drill-import"; @@ -91,6 +92,46 @@ function createRepository() { } describe("Eight2Five drill import", () => { + test("treats a canceled native picker result as a no-op", async () => { + const readText = jest.fn(async () => JSON.stringify(VALID_DOCUMENT)); + + await expect( + parseDrillPickerResult( + { canceled: true, assets: null } as const, + readText, + ), + ).resolves.toBeUndefined(); + expect(readText).not.toHaveBeenCalled(); + }); + + test("preserves picker file rules while reading the selected asset", async () => { + const readText = jest.fn(async (uri: string) => { + expect(uri).toBe("file:///cache/finale.json"); + return JSON.stringify(VALID_DOCUMENT); + }); + + await expect( + parseDrillPickerResult( + { + canceled: false, + assets: [ + { + name: "finale.eight2five.json", + size: 128, + uri: "file:///cache/finale.json", + mimeType: "application/json", + lastModified: 0, + }, + ], + }, + readText, + ), + ).resolves.toEqual({ + document: VALID_DOCUMENT, + fileName: "finale.eight2five.json", + }); + }); + test("recognizes the converter drill file extension", () => { expect(isEight2FiveDrillFileName("finale.eight2five.json")).toBe(true); expect(isEight2FiveDrillFileName("EIGHT2FIVE.JSON")).toBe(true); diff --git a/apps/mobile/src/features/drill/__tests__/drill-management.test.ts b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts index ad286764..371cdbbf 100644 --- a/apps/mobile/src/features/drill/__tests__/drill-management.test.ts +++ b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts @@ -2,12 +2,13 @@ import type { DrillRepository } from "@eight2five/mobile/drill"; import { DRILL_NAME_MAX_LENGTH, - createNamedDrill, deleteDrillAndRefreshSettings, + formatDrillCount, + getDrillCardActionLabels, loadDrillList, - renameNamedDrill, validateDrillName, } from "../drill-management"; +import { getDrillTerms } from "@eight2five/mobile/drill"; describe("manual drill management", () => { test("loads deterministic drill rows with page counts and supports empty state", async () => { @@ -43,50 +44,24 @@ describe("manual drill management", () => { await expect(loadDrillList(repository)).resolves.toEqual([]); }); - test("trims and validates names for create and rename", async () => { - const created = { id: "new", name: "Show", createdAt: 1, updatedAt: 1 }; - const repository = { - createDrill: jest.fn(async () => created), - renameDrill: jest.fn(async () => ({ ...created, name: "Finale" })), - } as unknown as DrillRepository; - - await expect(createNamedDrill(repository, " Show ")).resolves.toBe( - created, - ); - expect(repository.createDrill).toHaveBeenCalledWith({ - name: "Show", - fieldPreset: "football-nfhs", - }); - await renameNamedDrill(repository, "new", " Finale "); - expect(repository.renameDrill).toHaveBeenCalledWith("new", "Finale"); - + test("validates names for properties editing", () => { expect(validateDrillName(" ")).toBe("Enter a drill name."); expect(validateDrillName("x".repeat(DRILL_NAME_MAX_LENGTH + 1))).toContain( String(DRILL_NAME_MAX_LENGTH), ); - await expect(createNamedDrill(repository, " ")).rejects.toThrow( - "Enter a drill name", - ); }); - test("uses the selected default field preset for a new manual drill", async () => { - const created = { - id: "new", - name: "College Show", - fieldPreset: "football-ncaa" as const, - createdAt: 1, - updatedAt: 1, - }; - const repository = { - createDrill: jest.fn(async () => created), - } as unknown as DrillRepository; + test("formats card counts using the selected terminology", () => { + expect(formatDrillCount(1, getDrillTerms("sets"))).toBe("1 Set"); + expect(formatDrillCount(3, getDrillTerms("pages"))).toBe("3 Pages"); + }); - await expect( - createNamedDrill(repository, "College Show", "football-ncaa"), - ).resolves.toBe(created); - expect(repository.createDrill).toHaveBeenCalledWith({ - name: "College Show", - fieldPreset: "football-ncaa", + test("provides accessible labels for the three card actions", () => { + expect(getDrillCardActionLabels("Finale")).toEqual({ + info: "Info for Finale", + performer: "Select performer for Finale", + activate: "Activate Finale", + deactivate: "Deactivate Finale", }); }); diff --git a/apps/mobile/src/features/drill/__tests__/page-form.test.ts b/apps/mobile/src/features/drill/__tests__/page-form.test.ts index 1e1c0149..f5c47165 100644 --- a/apps/mobile/src/features/drill/__tests__/page-form.test.ts +++ b/apps/mobile/src/features/drill/__tests__/page-form.test.ts @@ -1,4 +1,4 @@ -import type { DrillRepository, DrillSet } from "@eight2five/mobile/drill"; +import type { DrillSet } from "@eight2five/mobile/drill"; import { formatMarchingFrontBack, formatMarchingSide, @@ -11,7 +11,6 @@ import { validatePageDraft, type MarchingCoordinateDraft, } from "../page-form"; -import { savePageDraft } from "../page-management"; const VALID_DRAFT: MarchingCoordinateDraft = { setNumber: "31", @@ -193,68 +192,4 @@ describe("structured marching coordinate form", () => { }).errors.side, ).toContain("50-yard line"); }); - - test("persists canonical create and edit payloads", async () => { - const parsed = validatePageDraft(VALID_DRAFT).value!; - const createdSet: DrillSet = { - id: "set-new", - drillId: "drill", - ordinal: 1, - number: parsed.number, - suffix: parsed.suffix, - kind: parsed.kind, - countsFromPrevious: parsed.countsFromPrevious, - measureRange: parsed.measureRange, - position: parsed.position, - }; - const repository = { - createSet: jest.fn(async () => createdSet), - updateSet: jest.fn(async () => createdSet), - } as unknown as DrillRepository; - - await savePageDraft({ - repository, - drillId: "drill", - pageId: "new", - pages: [ - { - ...createdSet, - id: "set-zero", - ordinal: 0, - number: 30, - suffix: undefined, - kind: "set", - countsFromPrevious: 0, - }, - ], - placement: "append", - draft: VALID_DRAFT, - }); - expect(repository.createSet).toHaveBeenCalledWith({ - drillId: "drill", - number: 31, - kind: "subset", - suffix: "A", - countsFromPrevious: 16, - measureRange: { start: 126, end: 129 }, - position: createdSet.position, - }); - - await savePageDraft({ - repository, - drillId: "drill", - pageId: "set-new", - pages: [createdSet], - placement: "append", - draft: VALID_DRAFT, - }); - expect(repository.updateSet).toHaveBeenCalledWith("set-new", { - number: 31, - kind: "subset", - suffix: "A", - countsFromPrevious: 16, - measureRange: { start: 126, end: 129 }, - position: createdSet.position, - }); - }); }); diff --git a/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts b/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts deleted file mode 100644 index 4503ab38..00000000 --- a/apps/mobile/src/features/drill/__tests__/page-ordering.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { - DrillRepository, - DrillSet, - TransitionAnalysis, -} from "@eight2five/mobile/drill"; - -import { - formatTransitionAnalysis, - getTransitionPresentation, -} from "../transition-presentation"; -import { createDefaultPageDraft } from "../page-form"; -import { - deletePageAndRefreshSettings, - getPageCreationOrdinal, - movePage, - normalizePagePlacement, - reorderedPageIds, - savePageDraft, -} from "../page-management"; - -function set(id: string, ordinal: number, xSteps = ordinal * 8): DrillSet { - return { - id, - drillId: "drill", - ordinal, - number: ordinal + 1, - kind: "set", - countsFromPrevious: ordinal === 0 ? 0 : 8, - position: { xSteps, ySteps: 0 }, - }; -} - -describe("set ordering and transition presentation", () => { - const sets = [set("a", 0), set("b", 1), set("c", 2)]; - - test("calculates append and insertion ordinals without deriving display identity", () => { - expect(normalizePagePlacement("before")).toBe("before"); - expect(normalizePagePlacement("after")).toBe("after"); - expect(normalizePagePlacement("malformed-deep-link")).toBe("append"); - expect(getPageCreationOrdinal(sets, "append")).toBe(3); - expect(getPageCreationOrdinal(sets, "before", "b")).toBe(1); - expect(getPageCreationOrdinal(sets, "after", "b")).toBe(2); - expect(() => getPageCreationOrdinal(sets, "before", "missing")).toThrow( - "insertion point", - ); - }); - - test("inserts before and after through the transactional repository contract", async () => { - const inserted = set("inserted", 1); - const repository = { - insertSet: jest.fn(async () => inserted), - } as unknown as DrillRepository; - const draft = createDefaultPageDraft({ ordinal: 1, suggestedNumber: 8 }); - - await savePageDraft({ - repository, - drillId: "drill", - pageId: "new", - pages: sets, - placement: "before", - relativePageId: "b", - draft, - }); - expect(repository.insertSet).toHaveBeenLastCalledWith( - "drill", - 1, - expect.objectContaining({ number: 8, kind: "set" }), - ); - - await savePageDraft({ - repository, - drillId: "drill", - pageId: "new", - pages: sets, - placement: "after", - relativePageId: "b", - draft, - }); - expect(repository.insertSet).toHaveBeenLastCalledWith( - "drill", - 2, - expect.objectContaining({ number: 8, kind: "set" }), - ); - }); - - test("moves stable IDs up and down and leaves boundaries unchanged", async () => { - expect(reorderedPageIds(sets, "b", "up")).toEqual(["b", "a", "c"]); - expect(reorderedPageIds(sets, "b", "down")).toEqual(["a", "c", "b"]); - expect(reorderedPageIds(sets, "a", "up")).toBeUndefined(); - expect(() => reorderedPageIds(sets, "missing", "up")).toThrow( - "no longer exists", - ); - - const reordered = [set("b", 0), set("a", 1), set("c", 2)]; - const repository = { - reorderSets: jest.fn(async () => reordered), - } as unknown as DrillRepository; - await expect(movePage(repository, "drill", sets, "b", "up")).resolves.toBe( - reordered, - ); - expect(repository.reorderSets).toHaveBeenCalledWith("drill", [ - "b", - "a", - "c", - ]); - }); - - test("deletes before publishing cleared selected-set state", async () => { - const order: string[] = []; - const repository = { - deleteSet: jest.fn(async () => { - order.push("delete"); - }), - } as unknown as DrillRepository; - await deletePageAndRefreshSettings(repository, "b", async () => { - order.push("reload"); - }); - expect(order).toEqual(["delete", "reload"]); - }); - - test("formats unavailable, Hold, Step Size, and xCounts values", () => { - const base: TransitionAnalysis = { - distanceSteps: 8, - stepSizeToFive: 6.5, - isHalt: false, - yardLineCrossingCounts: [4, 12], - }; - expect(formatTransitionAnalysis(base, false, 16)).toEqual({ - stepSize: "–", - crossingCounts: "–", - }); - expect(formatTransitionAnalysis(base, true, 0)).toEqual({ - stepSize: "–", - crossingCounts: "–", - }); - expect(formatTransitionAnalysis(base, true, 16)).toEqual({ - stepSize: "6.5 to 5", - crossingCounts: "4, 12", - }); - expect( - formatTransitionAnalysis( - { ...base, isHalt: true, stepSizeToFive: undefined }, - true, - 16, - ).stepSize, - ).toBe("Hold"); - }); - - test("recalculates both transitions neighboring a changed middle set", () => { - const originalMiddle = getTransitionPresentation(sets[0], sets[1]); - const originalFollowing = getTransitionPresentation(sets[1], sets[2]); - const changedMiddle = { - ...sets[1], - position: sets[0].position, - }; - const nextMiddle = getTransitionPresentation(sets[0], changedMiddle); - const nextFollowing = getTransitionPresentation(changedMiddle, sets[2]); - - expect(originalMiddle.stepSize).toBe("8 to 5"); - expect(originalFollowing.stepSize).toBe("8 to 5"); - expect(nextMiddle.stepSize).toBe("Hold"); - expect(nextFollowing.stepSize).toBe("4 to 5"); - }); -}); diff --git a/apps/mobile/src/features/drill/components/destructive-drill-actions.tsx b/apps/mobile/src/features/drill/components/destructive-drill-actions.tsx deleted file mode 100644 index be5443ca..00000000 --- a/apps/mobile/src/features/drill/components/destructive-drill-actions.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Alert } from "react-native"; -import { Check, Pencil, Trash2 } from "lucide-react-native"; -import type { Drill, DrillTerms } from "@eight2five/mobile/drill"; -import { - Actionsheet, - ActionsheetBackdrop, - ActionsheetContent, - ActionsheetDragIndicator, - ActionsheetDragIndicatorWrapper, - ActionsheetIcon, - ActionsheetItem, - ActionsheetItemText, -} from "@eight2five/ui/components/actionsheet"; -import { useEight2FiveTheme } from "@eight2five/ui/theme"; - -export function confirmDeleteDrill( - drill: Drill, - terms: DrillTerms, - onConfirm: () => void, -) { - Alert.alert( - `Delete “${drill.name}”?`, - `This permanently deletes the drill and all of its ${terms.lowercasePlural}.`, - [ - { text: "Cancel", style: "cancel" }, - { text: "Delete", style: "destructive", onPress: onConfirm }, - ], - ); -} - -export function DrillActionsSheet({ - drill, - active, - onClose, - onMakeActive, - onRename, - onDelete, -}: { - drill?: Drill; - active: boolean; - onClose(): void; - onMakeActive(): void; - onRename(): void; - onDelete(): void; -}) { - const theme = useEight2FiveTheme(); - return ( - - - - - - - {!active ? ( - - - Make active - - ) : null} - - - Rename - - - - - Delete - - - - - ); -} diff --git a/apps/mobile/src/features/drill/components/drill-empty-state.tsx b/apps/mobile/src/features/drill/components/drill-empty-state.tsx index d8549594..fcbd7a78 100644 --- a/apps/mobile/src/features/drill/components/drill-empty-state.tsx +++ b/apps/mobile/src/features/drill/components/drill-empty-state.tsx @@ -1,4 +1,4 @@ -import { FileUp, NotebookTabs } from "lucide-react-native"; +import { FileUp } from "lucide-react-native"; import type { DrillTerms } from "@eight2five/mobile/drill"; import { Button, @@ -7,7 +7,6 @@ import { } from "@eight2five/ui/components/button"; import { Center } from "@eight2five/ui/components/center"; import { Heading } from "@eight2five/ui/components/heading"; -import { Icon } from "@eight2five/ui/components/icon"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { @@ -30,7 +29,6 @@ export function DrillEmptyState({ className="items-center" style={{ gap: eight2FiveSpacing.md, maxWidth: 420 }} > - void; + readonly onSelectPerformer: () => void; + readonly onToggleActive: () => void; }) { const theme = useEight2FiveTheme(); - const countLabel = `${pageCount} ${ - pageCount === 1 ? terms.singular : terms.plural - }`; + const countLabel = formatDrillCount(pageCount, terms); + const actionLabels = getDrillCardActionLabels(drill.name); + const DrillIcon = resolveDrillIcon(drill.metadata?.lucideIcon); + return ( - - + - - - {drill.name} - - - {countLabel} - - {active ? ( - - Active - - ) : null} - - - - - + + + + + {drill.name} + + + {countLabel} + + + + + + + + + ); }); + +function DrillActionButton({ + label, + icon, + disabled, + onPress, + backgroundColor, + iconColor, +}: { + readonly label: string; + readonly icon: React.ElementType; + readonly disabled: boolean; + readonly onPress: () => void; + readonly backgroundColor: string; + readonly iconColor: string; +}) { + return ( + + + + ); +} diff --git a/apps/mobile/src/features/drill/components/drill-name-dialog.tsx b/apps/mobile/src/features/drill/components/drill-name-dialog.tsx deleted file mode 100644 index 389f4f80..00000000 --- a/apps/mobile/src/features/drill/components/drill-name-dialog.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { - Modal, - ModalBackdrop, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, -} from "@eight2five/ui/components/modal"; -import { Heading } from "@eight2five/ui/components/heading"; -import { Button, ButtonText } from "@eight2five/ui/components/button"; - -import { DrillNameForm } from "./drill-name-form"; - -export function DrillNameDialog({ - isOpen, - initialValue, - saving, - onClose, - onSave, -}: { - isOpen: boolean; - initialValue: string; - saving: boolean; - onClose(): void; - onSave(name: string): Promise; -}) { - return ( - - - - - Rename Drill - - - - - - - - - - ); -} diff --git a/apps/mobile/src/features/drill/components/drill-name-form.tsx b/apps/mobile/src/features/drill/components/drill-name-form.tsx deleted file mode 100644 index d25cc4a5..00000000 --- a/apps/mobile/src/features/drill/components/drill-name-form.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from "react"; -import { - Button, - ButtonSpinner, - ButtonText, -} from "@eight2five/ui/components/button"; -import { - FormControl, - FormControlError, - FormControlErrorText, - FormControlHelper, - FormControlHelperText, - FormControlLabel, - FormControlLabelText, -} from "@eight2five/ui/components/form-control"; -import { Input, InputField } from "@eight2five/ui/components/input"; -import { VStack } from "@eight2five/ui/components/vstack"; -import { eight2FiveSpacing } from "@eight2five/ui/theme"; - -import { DRILL_NAME_MAX_LENGTH, validateDrillName } from "../drill-management"; - -export function DrillNameForm({ - initialValue = "", - submitLabel, - saving, - onSubmit, -}: { - initialValue?: string; - submitLabel: string; - saving: boolean; - onSubmit(name: string): Promise; -}) { - const [name, setName] = React.useState(initialValue); - const [error, setError] = React.useState(); - const submittingRef = React.useRef(false); - - const submit = async () => { - if (saving || submittingRef.current) return; - const validationError = validateDrillName(name); - setError(validationError); - if (validationError) return; - submittingRef.current = true; - try { - await onSubmit(name); - } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); - } finally { - submittingRef.current = false; - } - }; - - return ( - - - - Drill name - - - { - setName(value); - if (error) setError(undefined); - }} - autoCapitalize="words" - autoCorrect - maxLength={DRILL_NAME_MAX_LENGTH} - returnKeyType="done" - submitBehavior="blurAndSubmit" - onSubmitEditing={() => void submit()} - accessibilityLabel="Drill name" - /> - - - - Up to {DRILL_NAME_MAX_LENGTH} characters. - - - - {error} - - - - - ); -} diff --git a/apps/mobile/src/features/drill/components/drill-page-actions.tsx b/apps/mobile/src/features/drill/components/drill-page-actions.tsx deleted file mode 100644 index 9a551819..00000000 --- a/apps/mobile/src/features/drill/components/drill-page-actions.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Alert } from "react-native"; -import { Flag, Pencil, Plus, Trash2 } from "lucide-react-native"; -import { - formatSetName, - type DrillSet, - type DrillTerms, -} from "@eight2five/mobile/drill"; -import { - Actionsheet, - ActionsheetBackdrop, - ActionsheetContent, - ActionsheetDragIndicator, - ActionsheetDragIndicatorWrapper, - ActionsheetIcon, - ActionsheetItem, - ActionsheetItemText, -} from "@eight2five/ui/components/actionsheet"; -import { useEight2FiveTheme } from "@eight2five/ui/theme"; - -export function confirmDeletePage( - page: DrillSet, - terms: DrillTerms, - onConfirm: () => void, -) { - Alert.alert( - `Delete ${terms.singular} ${formatSetName(page)}?`, - `This permanently deletes the ${terms.lowercaseSingular}.`, - [ - { text: "Cancel", style: "cancel" }, - { text: "Delete", style: "destructive", onPress: onConfirm }, - ], - ); -} - -export function DrillPageActionsSheet({ - page, - terms, - drillActive, - selected, - onClose, - onSelect, - onEdit, - onInsertBefore, - onInsertAfter, - onDelete, -}: { - page?: DrillSet; - terms: DrillTerms; - drillActive: boolean; - selected: boolean; - onClose(): void; - onSelect(): void; - onEdit(): void; - onInsertBefore(): void; - onInsertAfter(): void; - onDelete(): void; -}) { - const theme = useEight2FiveTheme(); - return ( - - - - - - - {drillActive && !selected ? ( - - - Select {terms.singular} - - ) : null} - - - Edit {terms.singular} - - - - - Insert {terms.lowercaseSingular} before - - - - - - Insert {terms.lowercaseSingular} after - - - - - - Delete {terms.singular} - - - - - ); -} diff --git a/apps/mobile/src/features/drill/components/drill-page-list-item.tsx b/apps/mobile/src/features/drill/components/drill-page-list-item.tsx deleted file mode 100644 index 59cd224f..00000000 --- a/apps/mobile/src/features/drill/components/drill-page-list-item.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React from "react"; -import { ArrowDown, ArrowUp, EllipsisVertical } from "lucide-react-native"; -import { - formatSetName, - type DrillSet, - type DrillTerms, -} from "@eight2five/mobile/drill"; -import type { FieldPresetId } from "@eight2five/drill-schema"; -import { - drillGridPointToMarchingCoordinate, - formatMarchingFrontBack, - formatMarchingSide, -} from "@eight2five/mobile/field"; -import { - Button, - ButtonIcon, - ButtonText, -} from "@eight2five/ui/components/button"; -import { Card } from "@eight2five/ui/components/card"; -import { HStack } from "@eight2five/ui/components/hstack"; -import { Icon } from "@eight2five/ui/components/icon"; -import { Pressable } from "@eight2five/ui/components/pressable"; -import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; -import { - eight2FiveFonts, - eight2FiveRadii, - eight2FiveSpacing, - useEight2FiveTheme, -} from "@eight2five/ui/theme"; - -import { TransitionSummary } from "./transition-summary"; - -export const DrillPageListItem = React.memo(function DrillPageListItem({ - page, - previousPage, - terms, - fieldPreset, - selected, - busy, - first, - last, - onEdit, - onMoveUp, - onMoveDown, - onOpenActions, -}: { - page: DrillSet; - previousPage?: DrillSet; - terms: DrillTerms; - fieldPreset: FieldPresetId; - selected: boolean; - busy: boolean; - first: boolean; - last: boolean; - onEdit(): void; - onMoveUp(): void; - onMoveDown(): void; - onOpenActions(): void; -}) { - const theme = useEight2FiveTheme(); - const coordinate = React.useMemo( - () => drillGridPointToMarchingCoordinate(page.position, fieldPreset), - [fieldPreset, page.position], - ); - const side = formatMarchingSide(coordinate.side); - const frontBack = formatMarchingFrontBack(coordinate.frontBack, fieldPreset); - const setName = formatSetName(page); - const title = `${terms.singular} ${setName}`; - const measures = page.measureRange - ? page.measureRange.start === page.measureRange.end - ? `Measure ${page.measureRange.start}` - : `Measures ${page.measureRange.start}–${page.measureRange.end}` - : undefined; - - return ( - - - - - - - {title} - - - {page.countsFromPrevious} counts - {measures ? ` · ${measures}` : ""} - - {selected ? ( - - Selected - - ) : null} - - {side} - {frontBack} - - - - - - - - - - - - - ); -}); diff --git a/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx new file mode 100644 index 00000000..3445b23b --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx @@ -0,0 +1,306 @@ +import React from "react"; +import { Alert } from "react-native"; +import { Check, Trash2 } from "lucide-react-native"; +import type { + Drill, + DrillDocument, + DrillTerms, + UpdateDrillPropertiesInput, +} from "@eight2five/mobile/drill"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { + FormControl, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { + Modal, + ModalBackdrop, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@eight2five/ui/components/modal"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { SettingsMessage } from "../../settings/settings-components"; +import { DRILL_ICON_NAMES, resolveDrillIcon } from "../drill-icons"; +import { DRILL_NAME_MAX_LENGTH, validateDrillName } from "../drill-management"; + +export function DrillPropertiesDialog({ + drill, + document, + terms, + isOpen, + loading, + saving, + error, + onClose, + onSave, + onDelete, +}: { + readonly drill?: Drill; + readonly document?: DrillDocument; + readonly terms: DrillTerms; + readonly isOpen: boolean; + readonly loading: boolean; + readonly saving: boolean; + readonly error?: Error; + readonly onClose: () => void; + readonly onSave: (input: UpdateDrillPropertiesInput) => Promise; + readonly onDelete: () => Promise; +}) { + const theme = useEight2FiveTheme(); + const [name, setName] = React.useState(drill?.name ?? ""); + const [iconName, setIconName] = React.useState( + drill?.metadata?.lucideIcon, + ); + const [formError, setFormError] = React.useState(); + + if (!drill) return null; + const metadata = document?.metadata ?? drill.metadata; + + const save = async () => { + const validationError = validateDrillName(name); + setFormError(validationError); + if (validationError) return; + try { + await onSave({ + name: name.trim(), + lucideIcon: iconName ?? null, + }); + } catch (cause) { + setFormError(cause instanceof Error ? cause.message : String(cause)); + } + }; + + const confirmDelete = () => { + Alert.alert( + `Delete “${drill.name}”?`, + `This permanently deletes the drill and all of its ${terms.lowercasePlural}.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => void onDelete().catch(() => undefined), + }, + ], + ); + }; + + return ( + { + if (!saving && !loading) onClose(); + }} + size="lg" + avoidKeyboard + > + + + + Drill Info + + + + {loading ? ( + Loading metadata… + ) : null} + {error ? ( + {error.message} + ) : null} + {formError ? ( + {formError} + ) : null} + + + + Drill name + + + { + setName(value); + if (formError) setFormError(undefined); + }} + maxLength={DRILL_NAME_MAX_LENGTH} + autoCapitalize="words" + accessibilityLabel="Drill name" + /> + + + + + + Card icon + + + setIconName(undefined)} + /> + {DRILL_ICON_NAMES.map((nameOption) => ( + setIconName(nameOption)} + /> + ))} + + + + + + + + + + + + + + + + + + + + ); +} + +function HStackWrap({ children }: { readonly children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function IconChoice({ + label, + selected, + icon, + onPress, +}: { + readonly label: string; + readonly selected: boolean; + readonly icon: React.ElementType; + readonly onPress: () => void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + ); +} + +function MetadataRow({ label, value }: { label: string; value?: string }) { + const theme = useEight2FiveTheme(); + return ( + + + {label} + + {value || "—"} + + ); +} + +function formatMetadataDate(value: string | undefined): string | undefined { + if (!value) return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : value; +} + +function formatSourceMetadata( + document: DrillDocument | undefined, +): string | undefined { + if (!document?.provenance) return undefined; + const source = document.provenance.source; + const importer = document.provenance.importer; + const values = [ + source?.fileName ?? source?.kind, + importer ? `${importer.name} ${importer.version}` : undefined, + ].filter((value): value is string => Boolean(value)); + return values.length > 0 ? values.join(" · ") : undefined; +} diff --git a/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx index 73a2df06..2603cc6f 100644 --- a/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx +++ b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx @@ -34,6 +34,9 @@ interface PerformerSelectionDialogProps { readonly isOpen: boolean; readonly importing: boolean; readonly error?: Error; + readonly selectedPerformerEntityId?: number; + readonly title?: string; + readonly confirmLabel?: string; readonly onClose: () => void; readonly onConfirm: (performerEntityId: number) => Promise; } @@ -50,6 +53,9 @@ function PerformerSelectionDialogContent({ isOpen, importing, error, + selectedPerformerEntityId, + title = "Select your dot", + confirmLabel = "Use This Dot", onClose, onConfirm, }: PerformerSelectionDialogProps & { readonly document: DrillDocument }) { @@ -59,9 +65,15 @@ function PerformerSelectionDialogContent({ () => getPerformerSymbolGroups(document), [document], ); - const [selectedSymbol, setSelectedSymbol] = React.useState(groups[0]?.symbol); - const [selectedPerformer, setSelectedPerformer] = - React.useState(); + const initiallySelectedPerformer = groups + .flatMap((group) => group.performers) + .find((performer) => performer.id === selectedPerformerEntityId); + const [selectedSymbol, setSelectedSymbol] = React.useState( + initiallySelectedPerformer?.symbol ?? groups[0]?.symbol, + ); + const [selectedPerformer, setSelectedPerformer] = React.useState< + DrillEntity | undefined + >(initiallySelectedPerformer); const visiblePerformers = groups.find((group) => group.symbol === selectedSymbol)?.performers ?? []; const listHeight = Math.min(380, Math.max(230, height * 0.45)); @@ -77,7 +89,7 @@ function PerformerSelectionDialogContent({ - Select your dot + {title} @@ -225,7 +237,7 @@ function PerformerSelectionDialogContent({ isDisabled={!selectedPerformer || importing} > {importing ? : null} - {importing ? "Importing…" : "Use This Dot"} + {importing ? "Saving…" : confirmLabel} diff --git a/apps/mobile/src/features/drill/components/transition-summary.tsx b/apps/mobile/src/features/drill/components/transition-summary.tsx deleted file mode 100644 index 9e7eb85b..00000000 --- a/apps/mobile/src/features/drill/components/transition-summary.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import { analyzeTransition, type DrillSet } from "@eight2five/mobile/drill"; -import { HStack } from "@eight2five/ui/components/hstack"; -import { Text } from "@eight2five/ui/components/text"; -import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; - -import { formatTransitionAnalysis } from "../transition-presentation"; - -export const TransitionSummary = React.memo(function TransitionSummary({ - previousPage, - page, -}: { - previousPage?: DrillSet; - page: DrillSet; -}) { - const theme = useEight2FiveTheme(); - const currentX = page.position.xSteps; - const currentY = page.position.ySteps; - const previousX = previousPage?.position.xSteps; - const previousY = previousPage?.position.ySteps; - const counts = page.countsFromPrevious; - const presentation = React.useMemo( - () => - formatTransitionAnalysis( - analyzeTransition( - previousX === undefined || previousY === undefined - ? undefined - : { xSteps: previousX, ySteps: previousY }, - { xSteps: currentX, ySteps: currentY }, - counts, - ), - previousX !== undefined && previousY !== undefined, - counts, - ), - [counts, currentX, currentY, previousX, previousY], - ); - - return ( - - - Step Size: {presentation.stepSize} - - - xCounts: {presentation.crossingCounts} - - - ); -}); diff --git a/apps/mobile/src/features/drill/drill-editor-screen.tsx b/apps/mobile/src/features/drill/drill-editor-screen.tsx deleted file mode 100644 index aa6758eb..00000000 --- a/apps/mobile/src/features/drill/drill-editor-screen.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import React from "react"; -import { useRouter } from "expo-router"; -import { Check, Pencil, Plus, Trash2 } from "lucide-react-native"; -import type { DrillPage } from "@eight2five/mobile/drill"; -import { - Button, - ButtonIcon, - ButtonSpinner, - ButtonText, -} from "@eight2five/ui/components/button"; -import { Card } from "@eight2five/ui/components/card"; -import { FlatList } from "@eight2five/ui/components/flat-list"; -import { Heading } from "@eight2five/ui/components/heading"; -import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; -import { - eight2FiveFonts, - eight2FiveRadii, - eight2FiveSpacing, - useEight2FiveTheme, -} from "@eight2five/ui/theme"; - -import { SettingsMessage } from "../settings/settings-components"; -import { confirmDeleteDrill } from "./components/destructive-drill-actions"; -import { - DrillPageActionsSheet, - confirmDeletePage, -} from "./components/drill-page-actions"; -import { DrillPageListItem } from "./components/drill-page-list-item"; -import { DrillNameDialog } from "./components/drill-name-dialog"; -import { useDrillEditorController } from "./use-drill-editor-controller"; - -export function DrillEditorScreen({ drillId }: { drillId: string }) { - const router = useRouter(); - const theme = useEight2FiveTheme(); - const controller = useDrillEditorController(drillId); - const [renaming, setRenaming] = React.useState(false); - const [actionPage, setActionPage] = React.useState(); - - const openPage = React.useCallback( - (page: DrillPage) => { - if (!drillId) return; - router.push({ - pathname: "/(tabs)/drill/[drillId]/page/[pageId]", - params: { drillId, pageId: page.id }, - }); - }, - [drillId, router], - ); - - const renderPage = React.useCallback( - ({ item, index }: { item: DrillPage; index: number }) => ( - openPage(item)} - onMoveUp={() => { - void controller.move(item, "up").catch(() => undefined); - }} - onMoveDown={() => { - void controller.move(item, "down").catch(() => undefined); - }} - onOpenActions={() => setActionPage(item)} - /> - ), - [controller, openPage], - ); - - const drill = controller.drill; - const deleteDrill = () => { - if (!drill) return; - confirmDeleteDrill(drill, controller.terms, () => { - void controller - .remove() - .then(() => router.replace("/(tabs)/drill")) - .catch(() => undefined); - }); - }; - - const insertRelativeToActionPage = (placement: "before" | "after") => { - const page = actionPage; - setActionPage(undefined); - if (!page) return; - router.push({ - pathname: "/(tabs)/drill/[drillId]/page/[pageId]", - params: { - drillId, - pageId: "new", - placement, - relativePageId: page.id, - }, - }); - }; - - const deleteActionPage = () => { - const page = actionPage; - setActionPage(undefined); - if (!page) return; - confirmDeletePage(page, controller.terms, () => { - void controller.removePage(page).catch(() => undefined); - }); - }; - - return ( - - page.id} - renderItem={renderPage} - contentInsetAdjustmentBehavior="automatic" - contentContainerStyle={{ - flexGrow: 1, - gap: eight2FiveSpacing.sm, - padding: eight2FiveSpacing.md, - paddingBottom: eight2FiveSpacing.xxl, - }} - ListHeaderComponent={ - - {controller.loading ? ( - Loading drill… - ) : null} - {controller.error ? ( - - {controller.error.message} - - ) : null} - {drill ? ( - <> - - - {drill.name} - - - {controller.pages.length}{" "} - {controller.pages.length === 1 - ? controller.terms.singular - : controller.terms.plural} - - - {controller.active ? "Active" : "Inactive"} - - - - - - {!controller.active ? ( - - ) : null} - - - - - {controller.terms.plural} - - - ) : null} - - } - ListEmptyComponent={ - !controller.loading && drill ? ( - - No {controller.terms.lowercasePlural} yet. Add one to begin. - - ) : null - } - /> - - {drill ? ( - setRenaming(false)} - onSave={async (name) => { - await controller.saveName(name); - setRenaming(false); - }} - /> - ) : null} - setActionPage(undefined)} - onSelect={() => { - const page = actionPage; - setActionPage(undefined); - if (page) void controller.selectPage(page).catch(() => undefined); - }} - onEdit={() => { - const page = actionPage; - setActionPage(undefined); - if (page) openPage(page); - }} - onInsertBefore={() => insertRelativeToActionPage("before")} - onInsertAfter={() => insertRelativeToActionPage("after")} - onDelete={deleteActionPage} - /> - - ); -} diff --git a/apps/mobile/src/features/drill/drill-icons.ts b/apps/mobile/src/features/drill/drill-icons.ts new file mode 100644 index 00000000..4a29cb9d --- /dev/null +++ b/apps/mobile/src/features/drill/drill-icons.ts @@ -0,0 +1,48 @@ +import { + Activity, + CircleDot, + Flag, + Music2, + Shapes, + Sparkles, + Star, + Trophy, + Zap, + type LucideIcon, +} from "lucide-react-native"; + +/** + * Only icons in this explicit registry may be resolved from imported + * metadata. Drill files are data, so their icon name must never be evaluated + * as a component lookup at runtime. + */ +export const DRILL_ICON_REGISTRY = Object.freeze({ + activity: Activity, + "circle-dot": CircleDot, + flag: Flag, + "music-2": Music2, + shapes: Shapes, + sparkles: Sparkles, + star: Star, + trophy: Trophy, + zap: Zap, +} satisfies Readonly>); + +export type DrillIconName = keyof typeof DRILL_ICON_REGISTRY; + +export const DRILL_ICON_NAMES = Object.freeze( + Object.keys(DRILL_ICON_REGISTRY) as DrillIconName[], +); + +export const FALLBACK_DRILL_ICON: LucideIcon = CircleDot; + +export function resolveDrillIcon(iconName: string | undefined): LucideIcon { + if (!iconName) return FALLBACK_DRILL_ICON; + return DRILL_ICON_REGISTRY[iconName as DrillIconName] ?? FALLBACK_DRILL_ICON; +} + +export function isSupportedDrillIcon( + iconName: string | undefined, +): iconName is DrillIconName { + return iconName !== undefined && iconName in DRILL_ICON_REGISTRY; +} diff --git a/apps/mobile/src/features/drill/drill-import.ts b/apps/mobile/src/features/drill/drill-import.ts index 500683ad..3d986e53 100644 --- a/apps/mobile/src/features/drill/drill-import.ts +++ b/apps/mobile/src/features/drill/drill-import.ts @@ -1,4 +1,8 @@ import type { Drill, DrillRepository } from "@eight2five/mobile/drill"; +import type { + DocumentPickerAsset, + DocumentPickerResult, +} from "expo-document-picker"; import { safeParseDrillDocument, type DrillDocument, @@ -8,6 +12,11 @@ import { export const EIGHT2FIVE_DRILL_FILE_SUFFIX = ".eight2five.json"; export const MAX_DRILL_UPLOAD_BYTES = 10 * 1024 * 1024; +export interface ParsedDrillPickerResult { + readonly document: DrillDocument; + readonly fileName: string; +} + export interface PerformerSymbolGroup { readonly symbol: string; readonly performers: readonly DrillEntity[]; @@ -21,6 +30,43 @@ export function isEight2FiveDrillFileName(fileName: string): boolean { ); } +/** + * Applies the existing file-name and size checks to a native picker asset, + * then reads and validates its JSON through the caller-provided Expo file + * reader. Keeping the reader injectable makes the import boundary testable + * without a native document picker or filesystem. + */ +export async function parseDrillPickerResult( + result: DocumentPickerResult, + readText: (uri: string) => Promise, +): Promise { + if (result.canceled) return undefined; + const asset = result.assets[0]; + if (!asset) return undefined; + return { + document: await parseDrillPickerAsset(asset, readText), + fileName: asset.name, + }; +} + +export async function parseDrillPickerAsset( + asset: Pick, + readText: (uri: string) => Promise, +): Promise { + if (!isEight2FiveDrillFileName(asset.name)) { + throw new Error(`Select a file ending in ${EIGHT2FIVE_DRILL_FILE_SUFFIX}.`); + } + if (typeof asset.size === "number" && asset.size > MAX_DRILL_UPLOAD_BYTES) { + throw new Error("The selected drill file is too large to import."); + } + + const json = await readText(asset.uri); + if (json.length > MAX_DRILL_UPLOAD_BYTES) { + throw new Error("The selected drill file is too large to import."); + } + return parseImportableDrillJson(json); +} + export function parseImportableDrillJson(json: string): DrillDocument { let value: unknown; try { diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx index 93d7ee30..126d9304 100644 --- a/apps/mobile/src/features/drill/drill-list-screen.tsx +++ b/apps/mobile/src/features/drill/drill-list-screen.tsx @@ -1,44 +1,23 @@ import React from "react"; -import { Stack, useRouter } from "expo-router"; +import { Stack } from "expo-router"; import { Plus } from "lucide-react-native"; -import type { Drill } from "@eight2five/mobile/drill"; import { FlatList } from "@eight2five/ui/components/flat-list"; -import { Heading } from "@eight2five/ui/components/heading"; import { Icon } from "@eight2five/ui/components/icon"; import { Pressable } from "@eight2five/ui/components/pressable"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; -import { - eight2FiveFonts, - eight2FiveSpacing, - useEight2FiveTheme, -} from "@eight2five/ui/theme"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; import { SettingsMessage } from "../settings/settings-components"; -import { - DrillActionsSheet, - confirmDeleteDrill, -} from "./components/destructive-drill-actions"; import { DrillEmptyState } from "./components/drill-empty-state"; import { DrillListItem } from "./components/drill-list-item"; -import { DrillNameDialog } from "./components/drill-name-dialog"; +import { DrillPropertiesDialog } from "./components/drill-properties-dialog"; +import { PerformerSelectionDialog } from "./components/performer-selection-dialog"; import { useDrillListController } from "./use-drill-list-controller"; export function DrillListScreen() { - const router = useRouter(); const theme = useEight2FiveTheme(); const controller = useDrillListController(); - const [actionDrill, setActionDrill] = React.useState(); - const [renameDrill, setRenameDrill] = React.useState(); - - const openDrill = React.useCallback( - (drill: Drill) => router.push(`/(tabs)/drill/${drill.id}`), - [router], - ); - - const openActions = React.useCallback((drill: Drill) => { - setActionDrill(drill); - }, []); const renderItem = React.useCallback( ({ item }: { item: (typeof controller.entries)[number] }) => ( @@ -47,41 +26,32 @@ export function DrillListScreen() { pageCount={item.pageCount} terms={controller.terms} active={controller.activeDrillId === item.drill.id} - busy={controller.busyDrillId === item.drill.id} - onOpen={() => openDrill(item.drill)} - onOpenActions={() => openActions(item.drill)} + busy={controller.uploadBusy || controller.busyDrillId === item.drill.id} + onOpenInfo={() => void controller.openProperties(item.drill)} + onSelectPerformer={() => + void controller.openPerformerSelection(item.drill) + } + onToggleActive={() => + void controller.toggleActive(item.drill).catch(() => undefined) + } /> ), - [controller, openActions, openDrill], + [controller], ); - const beginRename = () => { - setRenameDrill(actionDrill); - setActionDrill(undefined); - }; - - const beginDelete = () => { - const drill = actionDrill; - setActionDrill(undefined); - if (!drill) return; - confirmDeleteDrill(drill, controller.terms, () => { - void controller.remove(drill).catch(() => undefined); - }); - }; - return ( <> ( router.push("/(tabs)/drill/upload")} + onPress={() => void controller.pickFile()} accessibilityRole="button" accessibilityLabel="Upload Drill" hitSlop={8} style={{ - width: 40, - height: 40, + width: 48, + height: 48, alignItems: "center", justifyContent: "center", }} @@ -104,58 +74,95 @@ export function DrillListScreen() { paddingBottom: eight2FiveSpacing.xxl, }} ListHeaderComponent={ - - - Drills - - {controller.loading ? ( - Loading drills… - ) : null} - {controller.error ? ( - - {controller.error.message} - - ) : null} - + controller.loading || controller.error ? ( + + {controller.loading ? ( + + Loading drills… + + ) : null} + {controller.error ? ( + + {controller.error.message} + + ) : null} + + ) : null } ListEmptyComponent={ controller.loading ? null : ( router.push("/(tabs)/drill/upload")} + onUpload={() => void controller.pickFile()} /> ) } /> - setActionDrill(undefined)} - onMakeActive={() => { - const drill = actionDrill; - setActionDrill(undefined); - if (drill) { - void controller.makeActive(drill).catch(() => undefined); + { + if (controller.pendingImport) controller.cancelPendingImport(); + else controller.closePerformerSelection(); + }} + onConfirm={async (performerEntityId) => { + if (controller.pendingImport) { + await controller.importPendingDocument(performerEntityId); + } else { + await controller.selectPerformer(performerEntityId); } }} - onRename={beginRename} - onDelete={beginDelete} /> - setRenameDrill(undefined)} - onSave={async (name) => { - if (!renameDrill) return; - await controller.rename(renameDrill, name); - setRenameDrill(undefined); + + { + const drill = controller.propertiesDialog?.drill; + if (!drill) return; + await controller.remove(drill); }} /> diff --git a/apps/mobile/src/features/drill/drill-management.ts b/apps/mobile/src/features/drill/drill-management.ts index b5ad69b3..638ca31a 100644 --- a/apps/mobile/src/features/drill/drill-management.ts +++ b/apps/mobile/src/features/drill/drill-management.ts @@ -1,5 +1,8 @@ -import type { Drill, DrillRepository } from "@eight2five/mobile/drill"; -import type { FieldPresetId } from "@eight2five/drill-schema"; +import { + type Drill, + type DrillRepository, + type DrillTerms, +} from "@eight2five/mobile/drill"; export const DRILL_NAME_MAX_LENGTH = 80; @@ -21,6 +24,24 @@ export function validateDrillName(value: string): string | undefined { return undefined; } +export function formatDrillCount(count: number, terms: DrillTerms): string { + return `${count} ${count === 1 ? terms.singular : terms.plural}`; +} + +export function getDrillCardActionLabels(drillName: string): { + readonly info: string; + readonly performer: string; + readonly activate: string; + readonly deactivate: string; +} { + return { + info: `Info for ${drillName}`, + performer: `Select performer for ${drillName}`, + activate: `Activate ${drillName}`, + deactivate: `Deactivate ${drillName}`, + }; +} + export async function loadDrillList( repository: DrillRepository, ): Promise { @@ -35,29 +56,6 @@ export async function loadDrillList( })); } -export async function createNamedDrill( - repository: DrillRepository, - value: string, - fieldPreset: FieldPresetId = "football-nfhs", -): Promise { - const error = validateDrillName(value); - if (error) throw new Error(error); - return await repository.createDrill({ - name: normalizeDrillName(value), - fieldPreset, - }); -} - -export async function renameNamedDrill( - repository: DrillRepository, - drillId: string, - value: string, -): Promise { - const error = validateDrillName(value); - if (error) throw new Error(error); - return await repository.renameDrill(drillId, normalizeDrillName(value)); -} - export async function deleteDrillAndRefreshSettings( repository: DrillRepository, drillId: string, diff --git a/apps/mobile/src/features/drill/drill-upload-screen.tsx b/apps/mobile/src/features/drill/drill-upload-screen.tsx deleted file mode 100644 index 1ca41fc2..00000000 --- a/apps/mobile/src/features/drill/drill-upload-screen.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import React from "react"; -import * as DocumentPicker from "expo-document-picker"; -import { File } from "expo-file-system"; -import { useRouter } from "expo-router"; -import { FileUp } from "lucide-react-native"; -import type { DrillDocument } from "@eight2five/drill-schema"; -import { - Button, - ButtonIcon, - ButtonSpinner, - ButtonText, -} from "@eight2five/ui/components/button"; -import { Card } from "@eight2five/ui/components/card"; -import { ScrollView } from "@eight2five/ui/components/scroll-view"; -import { Text } from "@eight2five/ui/components/text"; -import { - eight2FiveRadii, - eight2FiveSpacing, - useEight2FiveTheme, -} from "@eight2five/ui/theme"; - -import { - useAppSettingsSnapshot, - useAppSettingsStore, -} from "../../state/app-settings-store"; -import { SettingsMessage } from "../settings/settings-components"; -import { PerformerSelectionDialog } from "./components/performer-selection-dialog"; -import { - EIGHT2FIVE_DRILL_FILE_SUFFIX, - MAX_DRILL_UPLOAD_BYTES, - importEight2FiveDrillDocument, - isEight2FiveDrillFileName, - parseImportableDrillJson, -} from "./drill-import"; -import { toError } from "./drill-management"; - -export function DrillUploadScreen() { - const router = useRouter(); - const theme = useEight2FiveTheme(); - const snapshot = useAppSettingsSnapshot(); - const store = useAppSettingsStore(); - const [busy, setBusy] = React.useState(false); - const [selectedFileName, setSelectedFileName] = React.useState(); - const [pendingDocument, setPendingDocument] = React.useState(); - const [error, setError] = React.useState(); - - const selectFile = React.useCallback(async () => { - if (snapshot.status !== "ready" || busy) return; - - setError(undefined); - try { - const result = await DocumentPicker.getDocumentAsync({ - copyToCacheDirectory: true, - multiple: false, - type: ["application/json", "text/json"], - }); - if (result.canceled) return; - - const asset = result.assets[0]; - if (!asset) return; - - setSelectedFileName(asset.name); - setBusy(true); - if (!isEight2FiveDrillFileName(asset.name)) { - throw new Error( - `Select a file ending in ${EIGHT2FIVE_DRILL_FILE_SUFFIX}.`, - ); - } - if ( - typeof asset.size === "number" && - asset.size > MAX_DRILL_UPLOAD_BYTES - ) { - throw new Error("The selected drill file is too large to import."); - } - - const json = await new File(asset.uri).text(); - if (json.length > MAX_DRILL_UPLOAD_BYTES) { - throw new Error("The selected drill file is too large to import."); - } - - setPendingDocument(parseImportableDrillJson(json)); - } catch (cause) { - setPendingDocument(undefined); - setError(toError(cause)); - } finally { - setBusy(false); - } - }, [busy, snapshot.status]); - - const importSelectedPerformer = React.useCallback( - async (performerEntityId: number) => { - if (!pendingDocument || snapshot.status !== "ready" || busy) return; - setBusy(true); - setError(undefined); - try { - const drill = await importEight2FiveDrillDocument( - store.getDrillRepository(), - pendingDocument, - performerEntityId, - ); - setPendingDocument(undefined); - router.replace(`/(tabs)/drill/${drill.id}`); - } catch (cause) { - setError(toError(cause)); - } finally { - setBusy(false); - } - }, - [busy, pendingDocument, router, snapshot.status, store], - ); - - const closePerformerSelection = React.useCallback(() => { - if (busy) return; - setPendingDocument(undefined); - setError(undefined); - }, [busy]); - - return ( - <> - - - - Upload an Eight2Five drill file exported as{" "} - - *{EIGHT2FIVE_DRILL_FILE_SUFFIX} - - . - - - Multi-performer files and props are supported. After selecting a - file, choose the performer dot whose coordinates you want this app - to use. - - - - {selectedFileName ? ( - - Selected: {selectedFileName} - - ) : null} - - {error && !pendingDocument ? ( - {error.message} - ) : null} - - - - - - - ); -} diff --git a/apps/mobile/src/features/drill/page-editor-screen.tsx b/apps/mobile/src/features/drill/page-editor-screen.tsx deleted file mode 100644 index 394565b7..00000000 --- a/apps/mobile/src/features/drill/page-editor-screen.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Stack, useRouter } from "expo-router"; -import { Save, X } from "lucide-react-native"; -import { - Button, - ButtonIcon, - ButtonSpinner, - ButtonText, -} from "@eight2five/ui/components/button"; -import { HStack } from "@eight2five/ui/components/hstack"; -import { ScrollView } from "@eight2five/ui/components/scroll-view"; -import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; -import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; - -import { SettingsMessage } from "../settings/settings-components"; -import { MarchingCoordinateForm } from "./components/marching-coordinate-form"; -import type { PagePlacement } from "./page-management"; -import { usePageEditorController } from "./use-page-editor-controller"; - -export function PageEditorScreen({ - drillId, - pageId, - placement = "append", - relativePageId, -}: { - drillId: string; - pageId: string; - placement?: PagePlacement; - relativePageId?: string; -}) { - const router = useRouter(); - const theme = useEight2FiveTheme(); - const controller = usePageEditorController( - drillId, - pageId, - placement, - relativePageId, - ); - const title = `${pageId === "new" ? "Add" : "Edit"} ${controller.terms.singular}`; - - return ( - - - - {controller.loading ? ( - - Loading {controller.terms.lowercaseSingular}… - - ) : null} - {controller.error ? ( - - {controller.error.message} - - ) : null} - {controller.draft ? ( - - ) : null} - - - - - - - ); -} diff --git a/apps/mobile/src/features/drill/page-management.ts b/apps/mobile/src/features/drill/page-management.ts deleted file mode 100644 index df42c9cd..00000000 --- a/apps/mobile/src/features/drill/page-management.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { DrillRepository, DrillSet } from "@eight2five/mobile/drill"; -import type { FieldPresetId } from "@eight2five/drill-schema"; - -import { validatePageDraft, type MarchingCoordinateDraft } from "./page-form"; - -export type SetPlacement = "append" | "before" | "after"; -export type SetMoveDirection = "up" | "down"; -/** @deprecated Use SetPlacement. */ -export type PagePlacement = SetPlacement; -/** @deprecated Use SetMoveDirection. */ -export type PageMoveDirection = SetMoveDirection; - -export function normalizePagePlacement(value: unknown): SetPlacement { - return value === "before" || value === "after" ? value : "append"; -} - -export function getPageCreationOrdinal( - sets: readonly DrillSet[], - placement: SetPlacement, - relativeSetId?: string, -): number { - if (placement === "append") return sets.length; - const relativeIndex = sets.findIndex((set) => set.id === relativeSetId); - if (relativeIndex < 0) { - throw new Error("The selected insertion point no longer exists."); - } - return placement === "before" ? relativeIndex : relativeIndex + 1; -} - -export async function savePageDraft({ - repository, - drillId, - pageId, - pages, - placement, - relativePageId, - draft, - fieldPreset = "football-nfhs", -}: { - repository: DrillRepository; - drillId: string; - pageId: string; - pages: readonly DrillSet[]; - placement: SetPlacement; - relativePageId?: string; - draft: MarchingCoordinateDraft; - fieldPreset?: FieldPresetId; -}): Promise { - const validation = validatePageDraft(draft, fieldPreset); - if (!validation.value) { - const message = - Object.values(validation.errors)[0] ?? "Review the drill position form."; - throw new Error(message); - } - const details = { - number: validation.value.number, - kind: validation.value.kind, - ...(validation.value.suffix === undefined - ? {} - : { suffix: validation.value.suffix }), - countsFromPrevious: validation.value.countsFromPrevious, - ...(validation.value.measureRange === undefined - ? {} - : { measureRange: validation.value.measureRange }), - position: validation.value.position, - }; - if (pageId !== "new") { - return await repository.updateSet(pageId, details); - } - - const ordinal = getPageCreationOrdinal(pages, placement, relativePageId); - if (placement === "append") { - return await repository.createSet({ drillId, ...details }); - } - return await repository.insertSet(drillId, ordinal, details); -} - -export function reorderedPageIds( - sets: readonly DrillSet[], - setId: string, - direction: SetMoveDirection, -): readonly string[] | undefined { - const index = sets.findIndex((set) => set.id === setId); - if (index < 0) throw new Error("The position to move no longer exists."); - const destination = direction === "up" ? index - 1 : index + 1; - if (destination < 0 || destination >= sets.length) return undefined; - const ids = sets.map((set) => set.id); - [ids[index], ids[destination]] = [ids[destination], ids[index]]; - return ids; -} - -export async function movePage( - repository: DrillRepository, - drillId: string, - sets: readonly DrillSet[], - setId: string, - direction: SetMoveDirection, -): Promise { - const ids = reorderedPageIds(sets, setId, direction); - return ids ? await repository.reorderSets(drillId, ids) : sets; -} - -export async function deletePageAndRefreshSettings( - repository: DrillRepository, - setId: string, - reloadSettings: () => Promise, -): Promise { - await repository.deleteSet(setId); - // Publish the selected-set pointer cleared by SQLite's foreign key. - await reloadSettings(); -} diff --git a/apps/mobile/src/features/drill/use-drill-editor-controller.ts b/apps/mobile/src/features/drill/use-drill-editor-controller.ts deleted file mode 100644 index 207b7c61..00000000 --- a/apps/mobile/src/features/drill/use-drill-editor-controller.ts +++ /dev/null @@ -1,224 +0,0 @@ -import React from "react"; -import { useFocusEffect } from "expo-router"; -import { - getDrillTerms, - type Drill, - type DrillSet, -} from "@eight2five/mobile/drill"; - -import { - useAppSettingsSnapshot, - useAppSettingsStore, -} from "../../state/app-settings-store"; -import { - deleteDrillAndRefreshSettings, - renameNamedDrill, - toError, -} from "./drill-management"; -import { - deletePageAndRefreshSettings, - movePage, - type PageMoveDirection, -} from "./page-management"; - -export function useDrillEditorController(drillId: string) { - const snapshot = useAppSettingsSnapshot(); - const store = useAppSettingsStore(); - const [drill, setDrill] = React.useState(); - const [pages, setPages] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [saving, setSaving] = React.useState(false); - const [busyPageId, setBusyPageId] = React.useState(); - const [error, setError] = React.useState(); - const operationInFlight = React.useRef(false); - - const refresh = React.useCallback(async () => { - if (snapshot.status !== "ready") return; - try { - const repository = store.getDrillRepository(); - const [nextDrill, nextPages] = await Promise.all([ - repository.getDrill(drillId), - repository.listSets(drillId), - ]); - if (!nextDrill) throw new Error("This drill no longer exists."); - setDrill(nextDrill); - setPages(nextPages); - setError(undefined); - } catch (cause) { - setError(toError(cause)); - } finally { - setLoading(false); - } - }, [drillId, snapshot.status, store]); - - useFocusEffect( - React.useCallback(() => { - void refresh(); - }, [refresh]), - ); - - const saveName = React.useCallback( - async (name: string) => { - if (operationInFlight.current) { - throw new Error("A save is already in progress."); - } - operationInFlight.current = true; - setSaving(true); - setError(undefined); - try { - const repository = store.getDrillRepository(); - const saved = await renameNamedDrill(repository, drillId, name); - setDrill(saved); - return saved; - } catch (cause) { - const operationError = toError(cause); - setError(operationError); - throw operationError; - } finally { - operationInFlight.current = false; - setSaving(false); - } - }, - [drillId, store], - ); - - const makeActive = React.useCallback(async () => { - if (!drillId) return; - if (operationInFlight.current) return; - operationInFlight.current = true; - setSaving(true); - setError(undefined); - try { - await store.setActiveDrill(drillId); - } catch (cause) { - const operationError = toError(cause); - setError(operationError); - throw operationError; - } finally { - operationInFlight.current = false; - setSaving(false); - } - }, [drillId, store]); - - const remove = React.useCallback(async () => { - if (!drillId) return; - if (operationInFlight.current) return; - operationInFlight.current = true; - setSaving(true); - setError(undefined); - try { - await deleteDrillAndRefreshSettings( - store.getDrillRepository(), - drillId, - () => store.reload(), - ); - } catch (cause) { - const operationError = toError(cause); - setError(operationError); - throw operationError; - } finally { - operationInFlight.current = false; - setSaving(false); - } - }, [drillId, store]); - - const selectPage = React.useCallback( - async (page: DrillSet) => { - if (snapshot.settings.activeDrillId !== drillId) { - const operationError = new Error( - "Make this drill active before selecting one of its entries.", - ); - setError(operationError); - throw operationError; - } - if (operationInFlight.current) return; - operationInFlight.current = true; - setBusyPageId(page.id); - setError(undefined); - try { - await store.setSelectedDrillSet(page.id); - } catch (cause) { - const operationError = toError(cause); - setError(operationError); - throw operationError; - } finally { - operationInFlight.current = false; - setBusyPageId(undefined); - } - }, - [drillId, snapshot.settings.activeDrillId, store], - ); - - const move = React.useCallback( - async (page: DrillSet, direction: PageMoveDirection) => { - if (!drillId || operationInFlight.current) return; - operationInFlight.current = true; - setBusyPageId(page.id); - setError(undefined); - try { - setPages( - await movePage( - store.getDrillRepository(), - drillId, - pages, - page.id, - direction, - ), - ); - } catch (cause) { - const operationError = toError(cause); - setError(operationError); - throw operationError; - } finally { - operationInFlight.current = false; - setBusyPageId(undefined); - } - }, - [drillId, pages, store], - ); - - const removePage = React.useCallback( - async (page: DrillSet) => { - if (!drillId || operationInFlight.current) return; - operationInFlight.current = true; - setBusyPageId(page.id); - setError(undefined); - try { - await deletePageAndRefreshSettings( - store.getDrillRepository(), - page.id, - () => store.reload(), - ); - setPages(await store.getDrillRepository().listSets(drillId)); - } catch (cause) { - const operationError = toError(cause); - setError(operationError); - throw operationError; - } finally { - operationInFlight.current = false; - setBusyPageId(undefined); - } - }, - [drillId, store], - ); - - return { - drillId, - drill, - pages, - loading: snapshot.status === "loading" || loading, - saving, - busyPageId, - active: snapshot.settings.activeDrillId === drillId, - selectedPageId: snapshot.settings.selectedDrillSetId, - terms: getDrillTerms(snapshot.settings.drillTerminology), - error: error ?? snapshot.error, - refresh, - saveName, - makeActive, - remove, - selectPage, - move, - removePage, - } as const; -} diff --git a/apps/mobile/src/features/drill/use-drill-list-controller.ts b/apps/mobile/src/features/drill/use-drill-list-controller.ts index e019a32a..f0b3abc4 100644 --- a/apps/mobile/src/features/drill/use-drill-list-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-list-controller.ts @@ -1,23 +1,45 @@ import React from "react"; +import * as DocumentPicker from "expo-document-picker"; +import { File } from "expo-file-system"; import { useFocusEffect } from "expo-router"; import { getDrillTerms, type Drill, + type DrillDocument, type DrillRepository, + type UpdateDrillPropertiesInput, } from "@eight2five/mobile/drill"; import { useAppSettingsSnapshot, useAppSettingsStore, } from "../../state/app-settings-store"; +import { + importEight2FiveDrillDocument, + parseDrillPickerResult, +} from "./drill-import"; import { deleteDrillAndRefreshSettings, loadDrillList, - renameNamedDrill, toError, type DrillListEntry, } from "./drill-management"; +interface PendingImport { + readonly document: DrillDocument; + readonly fileName: string; +} + +export interface DrillPropertiesDialogState { + readonly drill: Drill; + readonly document?: DrillDocument; +} + +export interface PerformerSelectionDialogState { + readonly drill?: Drill; + readonly document: DrillDocument; +} + export function useDrillListController() { const snapshot = useAppSettingsSnapshot(); const store = useAppSettingsStore(); @@ -25,7 +47,21 @@ export function useDrillListController() { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(); const [busyDrillId, setBusyDrillId] = React.useState(); + const [pendingImport, setPendingImport] = React.useState(); + const [selectedFileName, setSelectedFileName] = React.useState(); + const [uploadBusy, setUploadBusy] = React.useState(false); + const [importing, setImporting] = React.useState(false); + const [importError, setImportError] = React.useState(); + const [propertiesDialog, setPropertiesDialog] = + React.useState(); + const [propertiesLoading, setPropertiesLoading] = React.useState(false); + const [propertiesError, setPropertiesError] = React.useState(); + const [performerDialog, setPerformerDialog] = + React.useState(); + const [performerLoading, setPerformerLoading] = React.useState(false); + const [performerError, setPerformerError] = React.useState(); const mutationInFlight = React.useRef(false); + const pickerInFlight = React.useRef(false); const refresh = React.useCallback(async () => { if (snapshot.status !== "ready") return; @@ -72,24 +108,103 @@ export function useDrillListController() { [refresh, store], ); - const rename = React.useCallback( - async (drill: Drill, name: string) => - await mutate(drill.id, (repository) => - renameNamedDrill(repository, drill.id, name), - ), - [mutate], + const importDocument = React.useCallback( + async (document: DrillDocument, performerEntityId: number) => { + if (snapshot.status !== "ready" || mutationInFlight.current) return; + mutationInFlight.current = true; + setImporting(true); + setImportError(undefined); + setError(undefined); + try { + await importEight2FiveDrillDocument( + store.getDrillRepository(), + document, + performerEntityId, + ); + setPendingImport(undefined); + await refresh(); + } catch (cause) { + const importOperationError = toError(cause); + setImportError(importOperationError); + setError(importOperationError); + } finally { + mutationInFlight.current = false; + setImporting(false); + } + }, + [refresh, snapshot.status, store], ); - const makeActive = React.useCallback( - async (drill: Drill) => { - if (mutationInFlight.current) { - throw new Error("Another drill update is in progress."); + const pickFile = React.useCallback(async () => { + if ( + snapshot.status !== "ready" || + mutationInFlight.current || + pickerInFlight.current + ) { + return; + } + pickerInFlight.current = true; + setUploadBusy(true); + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ["application/json", "text/json", "text/plain"], + copyToCacheDirectory: true, + multiple: false, + }); + if (result.canceled) return; + + const parsed = await parseDrillPickerResult(result, (uri) => + new File(uri).text(), + ); + if (!parsed) return; + + setSelectedFileName(parsed.fileName); + setError(undefined); + setImportError(undefined); + const performers = parsed.document.entities.filter( + (entity) => entity.type === "performer", + ); + if (performers.length > 1) { + setPendingImport(parsed); + return; } + const performer = performers[0]; + if (!performer) return; + await importDocument(parsed.document, performer.id); + } catch (cause) { + const pickerError = toError(cause); + setError(pickerError); + setImportError(pickerError); + } finally { + pickerInFlight.current = false; + setUploadBusy(false); + } + }, [importDocument, snapshot.status]); + + const cancelPendingImport = React.useCallback(() => { + if (importing) return; + setPendingImport(undefined); + setImportError(undefined); + }, [importing]); + + const importPendingDocument = React.useCallback( + async (performerEntityId: number) => { + if (!pendingImport) return; + await importDocument(pendingImport.document, performerEntityId); + }, + [importDocument, pendingImport], + ); + + const toggleActive = React.useCallback( + async (drill: Drill) => { + if (mutationInFlight.current || snapshot.status !== "ready") return; mutationInFlight.current = true; setBusyDrillId(drill.id); setError(undefined); try { - await store.setActiveDrill(drill.id); + await store.setActiveDrill( + snapshot.settings.activeDrillId === drill.id ? null : drill.id, + ); } catch (cause) { const operationError = toError(cause); setError(operationError); @@ -99,7 +214,120 @@ export function useDrillListController() { setBusyDrillId(undefined); } }, - [store], + [snapshot.settings.activeDrillId, snapshot.status, store], + ); + + const openProperties = React.useCallback( + async (drill: Drill) => { + if (snapshot.status !== "ready") return; + setPropertiesDialog({ drill }); + setPropertiesLoading(true); + setPropertiesError(undefined); + try { + const document = await store + .getDrillRepository() + .getDrillDocument(drill.id); + setPropertiesDialog((current) => + current?.drill.id === drill.id ? { drill, document } : current, + ); + } catch (cause) { + setPropertiesError(toError(cause)); + } finally { + setPropertiesLoading(false); + } + }, + [snapshot.status, store], + ); + + const closeProperties = React.useCallback(() => { + if (!propertiesLoading) { + setPropertiesDialog(undefined); + setPropertiesError(undefined); + } + }, [propertiesLoading]); + + const updateProperties = React.useCallback( + async (input: UpdateDrillPropertiesInput) => { + const current = propertiesDialog; + if (!current) return; + try { + const saved = await mutate(current.drill.id, (repository) => + repository.updateDrillProperties(current.drill.id, input), + ); + setPropertiesError(undefined); + setPropertiesDialog((dialog) => + dialog?.drill.id === saved.id + ? { + ...dialog, + drill: saved, + document: dialog.document + ? { + ...dialog.document, + metadata: { + ...metadataWithSavedIcon( + dialog.document.metadata, + saved.metadata?.lucideIcon, + ), + title: saved.name, + }, + } + : undefined, + } + : dialog, + ); + } catch (cause) { + setPropertiesError(toError(cause)); + throw cause; + } + }, + [mutate, propertiesDialog], + ); + + const openPerformerSelection = React.useCallback( + async (drill: Drill) => { + if (snapshot.status !== "ready") return; + setPerformerLoading(true); + setPerformerError(undefined); + try { + const document = await store + .getDrillRepository() + .getDrillDocument(drill.id); + if (!document) { + throw new Error( + "This drill does not contain an imported performer list.", + ); + } + setPerformerDialog({ drill, document }); + } catch (cause) { + setPerformerError(toError(cause)); + } finally { + setPerformerLoading(false); + } + }, + [snapshot.status, store], + ); + + const closePerformerSelection = React.useCallback(() => { + if (!busyDrillId) { + setPerformerDialog(undefined); + setPerformerError(undefined); + } + }, [busyDrillId]); + + const selectPerformer = React.useCallback( + async (performerEntityId: number) => { + const current = performerDialog; + if (!current?.drill) return; + try { + await mutate(current.drill.id, (repository) => + repository.setSelectedPerformer(current.drill!.id, performerEntityId), + ); + setPerformerDialog(undefined); + } catch (cause) { + setPerformerError(toError(cause)); + } + }, + [mutate, performerDialog], ); const remove = React.useCallback( @@ -108,6 +336,7 @@ export function useDrillListController() { await deleteDrillAndRefreshSettings(repository, drill.id, () => store.reload(), ); + setPropertiesDialog(undefined); }), [mutate, store], ); @@ -120,8 +349,36 @@ export function useDrillListController() { activeDrillId: snapshot.settings.activeDrillId, terms: getDrillTerms(snapshot.settings.drillTerminology), refresh, - rename, - makeActive, + pickFile, + uploadBusy, + selectedFileName, + pendingImport, + importing, + importError, + cancelPendingImport, + importPendingDocument, + toggleActive, + openProperties, + closeProperties, + propertiesDialog, + propertiesLoading, + propertiesError, + updateProperties, + openPerformerSelection, + closePerformerSelection, + performerDialog, + performerLoading, + performerError, + selectPerformer, remove, } as const; } + +function metadataWithSavedIcon( + metadata: DrillDocument["metadata"], + lucideIcon: string | undefined, +): DrillDocument["metadata"] { + if (lucideIcon !== undefined) return { ...metadata, lucideIcon }; + const { lucideIcon: _ignored, ...withoutIcon } = metadata; + return withoutIcon; +} diff --git a/apps/mobile/src/features/drill/use-page-editor-controller.ts b/apps/mobile/src/features/drill/use-page-editor-controller.ts deleted file mode 100644 index 1c18054b..00000000 --- a/apps/mobile/src/features/drill/use-page-editor-controller.ts +++ /dev/null @@ -1,142 +0,0 @@ -import React from "react"; -import { useFocusEffect } from "expo-router"; -import { getDrillTerms, type DrillSet } from "@eight2five/mobile/drill"; -import type { FieldPresetId } from "@eight2five/drill-schema"; - -import { - useAppSettingsSnapshot, - useAppSettingsStore, -} from "../../state/app-settings-store"; -import { toError } from "./drill-management"; -import { - createDefaultPageDraft, - pageToDraft, - type MarchingCoordinateDraft, -} from "./page-form"; -import { - getPageCreationOrdinal, - savePageDraft, - type PagePlacement, -} from "./page-management"; - -export function usePageEditorController( - drillId: string, - pageId: string, - placement: PagePlacement = "append", - relativePageId?: string, -) { - const snapshot = useAppSettingsSnapshot(); - const store = useAppSettingsStore(); - const [page, setPage] = React.useState(); - const [pages, setPages] = React.useState([]); - const [fieldPreset, setFieldPreset] = - React.useState("football-nfhs"); - const [draft, setDraft] = React.useState(); - const [loading, setLoading] = React.useState(true); - const [saving, setSaving] = React.useState(false); - const [error, setError] = React.useState(); - const saveInFlight = React.useRef(false); - - const refresh = React.useCallback(async () => { - if (snapshot.status !== "ready") return; - try { - const repository = store.getDrillRepository(); - const [nextDrill, nextPages] = await Promise.all([ - repository.getDrill(drillId), - repository.listSets(drillId), - ]); - if (!nextDrill) throw new Error("This drill no longer exists."); - setFieldPreset(nextDrill.fieldPreset); - setPages(nextPages); - if (pageId === "new") { - const ordinal = getPageCreationOrdinal( - nextPages, - placement, - relativePageId, - ); - const highestPrimaryNumber = nextPages.reduce( - (highest, set) => - set.kind === "set" ? Math.max(highest, set.number) : highest, - -1, - ); - setDraft( - createDefaultPageDraft({ - ordinal, - suggestedNumber: highestPrimaryNumber + 1, - }), - ); - setPage(undefined); - } else { - const nextPage = await repository.getSet(pageId); - if (!nextPage || nextPage.drillId !== drillId) { - throw new Error("This drill entry no longer exists in the drill."); - } - setPage(nextPage); - setDraft(pageToDraft(nextPage, nextDrill.fieldPreset)); - } - setError(undefined); - } catch (cause) { - setError(toError(cause)); - } finally { - setLoading(false); - } - }, [drillId, pageId, placement, relativePageId, snapshot.status, store]); - - useFocusEffect( - React.useCallback(() => { - void refresh(); - }, [refresh]), - ); - - const save = React.useCallback(async () => { - if (!draft) throw new Error("The entry form is not ready."); - if (saveInFlight.current) throw new Error("A save is already in progress."); - saveInFlight.current = true; - setSaving(true); - setError(undefined); - try { - const saved = await savePageDraft({ - repository: store.getDrillRepository(), - drillId, - pageId, - pages, - placement, - relativePageId, - draft, - fieldPreset, - }); - setPage(saved); - return saved; - } catch (cause) { - const operationError = toError(cause); - setError(operationError); - throw operationError; - } finally { - saveInFlight.current = false; - setSaving(false); - } - }, [ - draft, - drillId, - fieldPreset, - pageId, - pages, - placement, - relativePageId, - store, - ]); - - return { - drillId, - pageId, - page, - draft, - setDraft, - fieldPreset, - loading: snapshot.status === "loading" || loading, - saving, - terms: getDrillTerms(snapshot.settings.drillTerminology), - error: error ?? snapshot.error, - save, - } as const; -} diff --git a/packages/mobile/src/drill/SqliteDrillRepository.ts b/packages/mobile/src/drill/SqliteDrillRepository.ts index f938f540..8fd95780 100644 --- a/packages/mobile/src/drill/SqliteDrillRepository.ts +++ b/packages/mobile/src/drill/SqliteDrillRepository.ts @@ -72,6 +72,19 @@ export interface UpdateDrillSetInput { readonly facingDegrees?: number | null; } +/** + * Updates the list-facing properties of a drill. Imported drills keep their + * canonical document and the query-friendly summary in sync in one + * transaction. + */ +export interface UpdateDrillPropertiesInput { + readonly name?: string; + /** Alias for name for callers that use the portable metadata vocabulary. */ + readonly title?: string; + /** Pass null to remove the optional icon; omit it to keep the current icon. */ + readonly lucideIcon?: string | null; +} + /** @deprecated Use CreateDrillSetDetails. */ export type CreateDrillPageDetails = CreateDrillSetDetails; /** @deprecated Use CreateDrillSetInput. */ @@ -91,6 +104,11 @@ export interface DrillRepository { /** Creates an imported drill and its local selected-performer projection atomically. */ createImportedDrill(input: CreateImportedDrillInput): Promise; renameDrill(id: string, name: string, updatedAt?: number): Promise; + updateDrillProperties( + id: string, + input: UpdateDrillPropertiesInput, + updatedAt?: number, + ): Promise; deleteDrill(id: string): Promise; getDrillDocument(drillId: string): Promise; setSelectedPerformer(drillId: string, entityId: number): Promise; @@ -352,6 +370,78 @@ export class SqliteDrillRepository implements DrillRepository { return requireValue(await this.getDrill(id), "drill", id); } + async updateDrillProperties( + idValue: string, + input: UpdateDrillPropertiesInput, + updatedAt?: number, + ): Promise { + const id = assertId(idValue, "Drill id"); + const current = await this.requireDrill(id); + if (input.name !== undefined && input.title !== undefined) { + throw invalidInput("Use either drill name or title, not both."); + } + const requestedName = input.name ?? input.title; + const name = + requestedName === undefined + ? current.name + : assertText(requestedName, "Drill name"); + const lucideIconChanged = input.lucideIcon !== undefined; + const lucideIcon = normalizeLucideIcon( + lucideIconChanged ? input.lucideIcon : current.metadata?.lucideIcon, + ); + const nextUpdatedAt = assertTimestamp( + updatedAt ?? this.timeFactory(), + "Drill updatedAt", + ); + const sourceDocument = await this.getDrillDocument(id); + const nextMetadata = updateMetadataProperties( + sourceDocument?.metadata ?? current.metadata, + name, + lucideIcon, + lucideIconChanged, + ); + const sourceDocumentJson = sourceDocument + ? serializeValidatedDrillDocument({ + ...sourceDocument, + metadata: nextMetadata, + }) + : undefined; + + await this.db.withTransactionAsync(async () => { + if (sourceDocumentJson === undefined) { + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, metadata_title = ?, metadata_lucide_icon = ?, updated_at = ? + WHERE id = ?`, + [ + name, + nextMetadata.title, + nextMetadata.lucideIcon ?? null, + nextUpdatedAt, + id, + ], + ); + } else { + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, metadata_title = ?, metadata_lucide_icon = ?, + source_document_json = ?, updated_at = ? + WHERE id = ?`, + [ + name, + nextMetadata.title, + nextMetadata.lucideIcon ?? null, + sourceDocumentJson, + nextUpdatedAt, + id, + ], + ); + } + }); + + return requireValue(await this.getDrill(id), "drill", id); + } + /** * Change the selected performer while rebuilding the indexed local set * projection. The portable source document is never mutated. @@ -1082,6 +1172,44 @@ function normalizeMetadata( }; } +function updateMetadataProperties( + metadata: DrillMetadata | undefined, + name: string, + lucideIcon: string | undefined, + lucideIconChanged: boolean, +): DrillMetadata { + if (!metadata) { + return { + title: name, + createdAt: timestampToIso(Date.now()), + ...(lucideIcon === undefined ? {} : { lucideIcon }), + }; + } + const existingMetadata = + lucideIconChanged && lucideIcon === undefined + ? (() => { + const { lucideIcon: _ignored, ...rest } = metadata; + return rest; + })() + : metadata; + return { + ...existingMetadata, + title: name, + ...(!lucideIconChanged || lucideIcon === undefined ? {} : { lucideIcon }), + }; +} + +function normalizeLucideIcon(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + if ( + typeof value !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.trim()) + ) { + throw invalidInput("Drill Lucide icon must be a kebab-case icon name."); + } + return value.trim(); +} + function assertSelectedPerformer( document: DrillDocument, entityId: number, diff --git a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts index 91384985..aa503933 100644 --- a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts +++ b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts @@ -189,6 +189,72 @@ describe("SqliteDrillRepository", () => { ); }); + test("updates imported properties transactionally in the summary and document", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + timeFactory: () => 123, + }); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + + await expect( + repository.updateDrillProperties(drill.id, { + name: "Updated Finale", + lucideIcon: "sparkles", + }), + ).resolves.toMatchObject({ + name: "Updated Finale", + metadata: expect.objectContaining({ + title: "Updated Finale", + lucideIcon: "sparkles", + }), + updatedAt: 123, + }); + expect(await repository.getDrillDocument(drill.id)).toEqual({ + ...IMPORTED_DOCUMENT, + metadata: { + ...IMPORTED_DOCUMENT.metadata, + title: "Updated Finale", + lucideIcon: "sparkles", + }, + }); + expect(fake.drills.get(drill.id)).toMatchObject({ + name: "Updated Finale", + metadata_title: "Updated Finale", + metadata_lucide_icon: "sparkles", + }); + expect(fake.database.withTransactionAsync).toHaveBeenCalled(); + + await repository.updateDrillProperties(drill.id, { lucideIcon: null }); + expect((await repository.getDrillDocument(drill.id))?.metadata).toEqual({ + createdAt: IMPORTED_DOCUMENT.metadata.createdAt, + drillWriter: IMPORTED_DOCUMENT.metadata.drillWriter, + ensemble: IMPORTED_DOCUMENT.metadata.ensemble, + description: IMPORTED_DOCUMENT.metadata.description, + title: "Updated Finale", + }); + }); + + test("rejects invalid drill property values without changing the document", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + + await expect( + repository.updateDrillProperties(drill.id, { lucideIcon: "Not An Icon" }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + expect(await repository.getDrillDocument(drill.id)).toEqual( + IMPORTED_DOCUMENT, + ); + }); + test("rejects imported fields passed through manual drill creation", async () => { const fake = new DrillFakeDatabase(); const repository = new SqliteDrillRepository(fake.database, { @@ -882,6 +948,41 @@ class DrillFakeDatabase { selected_performer_entity_id: selectedPerformerEntityId, }); } + } else if ( + sql.includes("UPDATE drills") && + sql.includes("metadata_lucide_icon") + ) { + const row = this.drills.get(String(params[params.length - 1])); + if (row) { + const hasSourceDocument = sql.includes("source_document_json"); + if (hasSourceDocument) { + const [name, metadataTitle, icon, sourceDocumentJson, updatedAt] = + params as [string, string, string | null, string, number, string]; + this.drills.set(row.id, { + ...row, + name, + metadata_title: metadataTitle, + metadata_lucide_icon: icon, + source_document_json: sourceDocumentJson, + updated_at: updatedAt, + }); + } else { + const [name, metadataTitle, icon, updatedAt] = params as [ + string, + string, + string | null, + number, + string, + ]; + this.drills.set(row.id, { + ...row, + name, + metadata_title: metadataTitle, + metadata_lucide_icon: icon, + updated_at: updatedAt, + }); + } + } } else if (sql.includes("UPDATE drills")) { const [name, updatedAt, id] = params as [string, number, string]; const row = this.drills.get(id); From 3cde75dc66e43a83f564e98ff7e2e2235cc60d60 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 13:02:00 -0500 Subject: [PATCH 068/101] feat(drill): derive transition marker paths --- .../drill/__tests__/transition-scene.test.ts | 416 +++++++++++++ packages/mobile/src/drill/index.ts | 2 + .../mobile/src/drill/transition-geometry.ts | 547 ++++++++++++++++++ packages/mobile/src/drill/transition-scene.ts | 414 +++++++++++++ 4 files changed, 1379 insertions(+) create mode 100644 packages/mobile/src/drill/__tests__/transition-scene.test.ts create mode 100644 packages/mobile/src/drill/transition-geometry.ts create mode 100644 packages/mobile/src/drill/transition-scene.ts diff --git a/packages/mobile/src/drill/__tests__/transition-scene.test.ts b/packages/mobile/src/drill/__tests__/transition-scene.test.ts new file mode 100644 index 00000000..26736405 --- /dev/null +++ b/packages/mobile/src/drill/__tests__/transition-scene.test.ts @@ -0,0 +1,416 @@ +import { + areGridPointsEquivalent, + buildTransitionScene, + cubicBezierPoint, + DEFAULT_GRID_POINT_EPSILON_STEPS, + measureTransitionGeometry, + type TransitionSceneSettings, +} from ".."; +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + type DrillDocument, + type DrillGridPoint, +} from "@eight2five/drill-schema"; + +const ENTITY_ID = 7; + +function settings( + overrides: Partial = {}, +): TransitionSceneSettings { + return { + markerEnabled: true, + showAll: false, + previousTotalCount: 1, + nextTotalCount: 1, + ...overrides, + }; +} + +function documentFor( + points: readonly DrillGridPoint[], + paths?: DrillDocument["paths"], +): DrillDocument { + return { + schema: DRILL_SCHEMA_URL, + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: "Transition test", + createdAt: "2026-08-05T00:00:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entities: [{ id: ENTITY_ID, type: "performer", symbol: "P", label: "P1" }], + sets: points.map((_, id) => ({ + id, + number: id + 1, + kind: "set" as const, + countsFromPrevious: id === 0 ? 0 : 8, + })), + positions: points.map((point, setId) => ({ + entityId: ENTITY_ID, + setId, + ...point, + })), + ...(paths === undefined ? {} : { paths }), + }; +} + +describe("transition geometry", () => { + test("uses the analytic midpoint for a straight transition", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 10, ySteps: 4 }, + ]), + ENTITY_ID, + 1, + settings(), + ); + + expect(scene.current).toEqual({ xSteps: 10, ySteps: 4 }); + expect(scene.previous?.geometry).toEqual({ + kind: "straight", + start: { xSteps: 0, ySteps: 0 }, + end: { xSteps: 10, ySteps: 4 }, + }); + expect(scene.previous?.midpoint).toEqual({ xSteps: 5, ySteps: 2 }); + }); + + test("finds the midpoint by distance on unequal polyline segments", () => { + const scene = buildTransitionScene( + documentFor( + [ + { xSteps: 0, ySteps: 0 }, + { xSteps: 1, ySteps: 3 }, + ], + [ + { + entityId: ENTITY_ID, + fromSetId: 0, + toSetId: 1, + kind: "polyline", + waypoints: [{ xSteps: 1, ySteps: 0 }], + }, + ], + ), + ENTITY_ID, + 1, + settings(), + ); + + expect(scene.previous?.geometry.kind).toBe("polyline"); + expect(scene.previous?.lengthSteps).toBe(4); + expect(scene.previous?.midpoint).toEqual({ xSteps: 1, ySteps: 1 }); + }); + + test("uses a true half-arc-length midpoint for an asymmetric Bézier", () => { + const geometry = { + kind: "bezier" as const, + start: { xSteps: 0, ySteps: 0 }, + controlPoints: [ + { xSteps: 0, ySteps: 140 }, + { xSteps: 18, ySteps: -12 }, + ] as const, + end: { xSteps: 100, ySteps: 0 }, + }; + const measured = measureTransitionGeometry(geometry, { tolerance: 1e-6 }); + const parameterHalfPoint = cubicBezierPoint( + geometry.start, + geometry.controlPoints[0], + geometry.controlPoints[1], + geometry.end, + 0.5, + ); + + expect(measured.midpointParameter).not.toBeCloseTo(0.5, 3); + expect(measured.midpoint.xSteps).not.toBeCloseTo( + parameterHalfPoint.xSteps, + 3, + ); + expect(measured.midpoint.ySteps).not.toBeCloseTo( + parameterHalfPoint.ySteps, + 3, + ); + }); + + test("uses the same coarse chord LUT metric for length and midpoint", () => { + const geometry = { + kind: "bezier" as const, + start: { xSteps: 0, ySteps: 0 }, + controlPoints: [ + { xSteps: 0, ySteps: 140 }, + { xSteps: 18, ySteps: -12 }, + ] as const, + end: { xSteps: 100, ySteps: 0 }, + }; + const measured = measureTransitionGeometry(geometry, { + tolerance: 1e-6, + maxSubdivisionDepth: 0, + }); + const expectedMidpoint = cubicBezierPoint( + geometry.start, + geometry.controlPoints[0], + geometry.controlPoints[1], + geometry.end, + 0.5, + ); + + expect(measured.lengthSteps).toBe(100); + expect(measured.midpointParameter).toBe(0.5); + expect(measured.midpoint).toEqual(expectedMidpoint); + }); + + test("falls back to a straight path when no matching path exists", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -4, ySteps: 2 }, + { xSteps: 8, ySteps: -6 }, + ]), + ENTITY_ID, + 1, + settings(), + ); + + expect(scene.previous?.geometry.kind).toBe("straight"); + }); + + test("matches an explicit path by entity and both set IDs", () => { + const scene = buildTransitionScene( + documentFor( + [ + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + { xSteps: 20, ySteps: 0 }, + ], + [ + { + entityId: ENTITY_ID, + fromSetId: 1, + toSetId: 2, + kind: "bezier", + controlPoints: [ + { xSteps: 12, ySteps: 10 }, + { xSteps: 16, ySteps: 10 }, + ], + }, + ], + ), + ENTITY_ID, + 2, + settings(), + ); + + expect(scene.previous?.geometry.kind).toBe("bezier"); + expect(scene.previous?.start).toEqual({ xSteps: 8, ySteps: 0 }); + expect(scene.previous?.end).toEqual({ xSteps: 20, ySteps: 0 }); + }); +}); + +describe("transition scene marker state", () => { + test("suppresses the marker entering a hold", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + ]), + ENTITY_ID, + 1, + settings(), + ); + + expect(scene.previous).toBeDefined(); + expect(scene.next).toBeUndefined(); + }); + + test("suppresses both sides inside a hold chain", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + ]), + ENTITY_ID, + 2, + settings(), + ); + + expect(scene.previous).toBeUndefined(); + expect(scene.next).toBeUndefined(); + }); + + test("shows the next marker when moving out of a hold", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + ]), + ENTITY_ID, + 2, + settings({ previousTotalCount: 0 }), + ); + + expect(scene.next?.fromSetId).toBe(2); + expect(scene.next?.toSetId).toBe(3); + }); + + test("handles the first and last source sets", () => { + const document = documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + ]); + const first = buildTransitionScene( + document, + ENTITY_ID, + 0, + settings({ nextTotalCount: 0 }), + ); + const last = buildTransitionScene(document, ENTITY_ID, 1, settings()); + + expect(first.previous).toBeUndefined(); + expect(first.next).toBeUndefined(); + expect(last.previous).toBeDefined(); + expect(last.next).toBeUndefined(); + }); + + test("showAll overrides detailed counts", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 1, ySteps: 0 }, + { xSteps: 2, ySteps: 0 }, + { xSteps: 3, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + ]), + ENTITY_ID, + 3, + settings({ showAll: true, previousTotalCount: 0, nextTotalCount: 0 }), + ); + + expect(scene.previous?.fromSetId).toBe(2); + expect(scene.previousDots.map((dot) => dot.setId)).toEqual([1, 0]); + expect(scene.next?.toSetId).toBe(4); + }); + + test("uses total-count windows without backfilling a suppressed immediate marker", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -8, ySteps: 0 }, + { xSteps: -4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 3, + settings({ previousTotalCount: 2, nextTotalCount: 0 }), + ); + + expect(scene.previous).toBeUndefined(); + expect(scene.previousDots.map((dot) => dot.setId)).toEqual([1]); + expect(scene.previousDots).not.toContainEqual({ + setId: 0, + point: { xSteps: -8, ySteps: 0 }, + }); + }); + + test("deduplicates coincident extras without removing the immediate marker", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 4, + settings({ previousTotalCount: 4, nextTotalCount: 0 }), + ); + + expect(scene.previous?.fromSetId).toBe(3); + expect(scene.previousDots.map((dot) => dot.setId)).toEqual([1]); + }); + + test("deduplicates extras scene-wide with previous extras taking priority", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 8, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 2, + settings({ previousTotalCount: 3, nextTotalCount: 3 }), + ); + + expect(scene.previous).toBeDefined(); + expect(scene.next).toBeDefined(); + expect(scene.previousDots).toEqual([ + { setId: 0, point: { xSteps: 8, ySteps: 0 } }, + ]); + expect(scene.nextDots).toEqual([]); + }); + + test("suppresses an extra that overlaps the opposite immediate endpoint", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 6, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + { xSteps: 6, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 2, + settings({ previousTotalCount: 3, nextTotalCount: 3 }), + ); + + expect(scene.previous).toBeDefined(); + expect(scene.next).toBeDefined(); + expect(scene.previousDots).toEqual([]); + expect(scene.nextDots).toEqual([ + { setId: 4, point: { xSteps: 8, ySteps: 0 } }, + ]); + }); + + test("keeps the current point while hiding all transition markers when disabled", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 1, + settings({ markerEnabled: false, showAll: true }), + ); + + expect(scene.current).toEqual({ xSteps: 8, ySteps: 0 }); + expect(scene.previous).toBeUndefined(); + expect(scene.next).toBeUndefined(); + expect(scene.previousDots).toEqual([]); + expect(scene.nextDots).toEqual([]); + }); + + test("defines epsilon equivalence for hold suppression", () => { + expect(DEFAULT_GRID_POINT_EPSILON_STEPS).toBeGreaterThan(0); + expect( + areGridPointsEquivalent( + { xSteps: 10, ySteps: -3 }, + { xSteps: 10 + DEFAULT_GRID_POINT_EPSILON_STEPS / 2, ySteps: -3 }, + ), + ).toBe(true); + expect( + areGridPointsEquivalent( + { xSteps: 10, ySteps: -3 }, + { xSteps: 10 + DEFAULT_GRID_POINT_EPSILON_STEPS * 2, ySteps: -3 }, + ), + ).toBe(false); + }); +}); diff --git a/packages/mobile/src/drill/index.ts b/packages/mobile/src/drill/index.ts index 64f6ce09..92abfef2 100644 --- a/packages/mobile/src/drill/index.ts +++ b/packages/mobile/src/drill/index.ts @@ -9,4 +9,6 @@ export { export * from "./types"; export * from "./terminology"; export * from "./analysis"; +export * from "./transition-geometry"; +export * from "./transition-scene"; export * from "./SqliteDrillRepository"; diff --git a/packages/mobile/src/drill/transition-geometry.ts b/packages/mobile/src/drill/transition-geometry.ts new file mode 100644 index 00000000..76e6925e --- /dev/null +++ b/packages/mobile/src/drill/transition-geometry.ts @@ -0,0 +1,547 @@ +import type { + DrillDocument, + DrillGridPoint, + DrillPath, +} from "@eight2five/drill-schema"; + +/** + * Grid coordinates are imported from external documents, so comparing their + * object identity would make nearly-identical hold positions appear to move. + */ +export const DEFAULT_GRID_POINT_EPSILON_STEPS = 1e-6; + +/** The default maximum error used while flattening a cubic Bézier in steps. */ +export const DEFAULT_BEZIER_ARC_LENGTH_TOLERANCE_STEPS = 1e-4; + +/** Prevents pathological documents from causing unbounded subdivision. */ +export const DEFAULT_BEZIER_MAX_SUBDIVISION_DEPTH = 18; +export const MAX_BEZIER_SUBDIVISION_DEPTH = 24; + +export interface TransitionGeometryOptions { + /** Maximum flattening error, measured in drill-grid steps. */ + readonly tolerance?: number; + /** Maximum recursive depth used by the deterministic cubic subdivision. */ + readonly maxSubdivisionDepth?: number; +} + +export interface StraightTransitionGeometry { + readonly kind: "straight"; + readonly start: DrillGridPoint; + readonly end: DrillGridPoint; +} + +export interface PolylineTransitionGeometry { + readonly kind: "polyline"; + /** Includes the transition's start and end points. */ + readonly points: readonly DrillGridPoint[]; +} + +export interface BezierTransitionGeometry { + readonly kind: "bezier"; + readonly start: DrillGridPoint; + readonly controlPoints: readonly [DrillGridPoint, DrillGridPoint]; + readonly end: DrillGridPoint; +} + +/** + * Geometry is intentionally made only from serializable grid points. Phase 4 + * can use this same DTO to construct a Skia path and to place its midpoint. + */ +export type TransitionPathGeometry = + | StraightTransitionGeometry + | PolylineTransitionGeometry + | BezierTransitionGeometry; + +export interface ResolvedTransitionGeometry { + readonly geometry: TransitionPathGeometry; + readonly lengthSteps: number; + readonly midpoint: DrillGridPoint; + /** Only populated for cubic Bézier geometry. */ + readonly midpointParameter?: number; +} + +/** + * Compare grid points using coordinate tolerance rather than object equality. + */ +export function areGridPointsEquivalent( + a: DrillGridPoint, + b: DrillGridPoint, + epsilon = DEFAULT_GRID_POINT_EPSILON_STEPS, +): boolean { + assertEpsilon(epsilon); + return ( + Math.abs(a.xSteps - b.xSteps) <= epsilon && + Math.abs(a.ySteps - b.ySteps) <= epsilon + ); +} + +/** Evaluate a cubic Bézier at parameter t in [0, 1]. */ +export function evaluateCubicBezier( + start: DrillGridPoint, + control1: DrillGridPoint, + control2: DrillGridPoint, + end: DrillGridPoint, + t: number, +): DrillGridPoint { + if (!Number.isFinite(t) || t < 0 || t > 1) { + throw new RangeError("A cubic Bézier parameter must be between 0 and 1."); + } + + const oneMinusT = 1 - t; + const oneMinusTSquared = oneMinusT * oneMinusT; + const tSquared = t * t; + const startWeight = oneMinusTSquared * oneMinusT; + const control1Weight = 3 * oneMinusTSquared * t; + const control2Weight = 3 * oneMinusT * tSquared; + const endWeight = tSquared * t; + + return { + xSteps: + start.xSteps * startWeight + + control1.xSteps * control1Weight + + control2.xSteps * control2Weight + + end.xSteps * endWeight, + ySteps: + start.ySteps * startWeight + + control1.ySteps * control1Weight + + control2.ySteps * control2Weight + + end.ySteps * endWeight, + }; +} + +/** Alias with the noun-first spelling used by some geometry callers. */ +export const cubicBezierPoint = evaluateCubicBezier; + +/** + * Approximate the arc length of a cubic Bézier using deterministic adaptive + * subdivision. The result is in drill-grid steps. + */ +export function approximateCubicBezierLength( + geometry: BezierTransitionGeometry, + options: TransitionGeometryOptions = {}, +): number { + const subdivision = normalizeSubdivisionOptions(options); + return buildBezierArcLengthLut(geometry, subdivision).lengthSteps; +} + +/** + * Resolve one entity transition from a complete document. + * + * A missing explicit path is deliberately represented as a straight segment; + * endpoint positions still come from the document, so this fallback cannot + * silently connect the wrong entity or set. + */ +export function resolveTransitionGeometry( + document: DrillDocument, + entityId: number, + fromSetId: number, + toSetId: number, + options: TransitionGeometryOptions = {}, +): ResolvedTransitionGeometry | undefined { + const start = findPosition(document, entityId, fromSetId); + const end = findPosition(document, entityId, toSetId); + if (!start || !end) return undefined; + + const geometry = resolvePathWithEndpoints( + document, + entityId, + fromSetId, + toSetId, + start, + end, + ); + return measureTransitionGeometry(geometry, options); +} + +/** + * Resolve only the path shape. Supplying endpoint positions separately keeps + * this helper useful to the scene builder without making path data authoritative + * over the position table. + */ +export function resolveTransitionPath( + document: DrillDocument, + entityId: number, + fromSetId: number, + toSetId: number, +): TransitionPathGeometry | undefined { + const start = findPosition(document, entityId, fromSetId); + const end = findPosition(document, entityId, toSetId); + if (!start || !end) return undefined; + return resolvePathWithEndpoints( + document, + entityId, + fromSetId, + toSetId, + start, + end, + ); +} + +/** Calculate length and the true half-arc-length point for already-resolved geometry. */ +export function measureTransitionGeometry( + geometry: TransitionPathGeometry, + options: TransitionGeometryOptions = {}, +): ResolvedTransitionGeometry { + switch (geometry.kind) { + case "straight": { + const lengthSteps = distanceBetween(geometry.start, geometry.end); + return { + geometry, + lengthSteps, + midpoint: interpolatePoint(geometry.start, geometry.end, 0.5), + }; + } + case "polyline": { + const measured = measurePolyline(geometry.points); + return { + geometry, + lengthSteps: measured.lengthSteps, + midpoint: measured.midpoint, + }; + } + case "bezier": { + const subdivision = normalizeSubdivisionOptions(options); + const lut = buildBezierArcLengthLut(geometry, subdivision); + const midpointResult = locateBezierArcLengthPoint(geometry, lut); + return { + geometry, + lengthSteps: lut.lengthSteps, + midpoint: midpointResult.point, + midpointParameter: midpointResult.parameter, + }; + } + } +} + +interface NormalizedSubdivisionOptions { + readonly tolerance: number; + readonly maxSubdivisionDepth: number; +} + +interface BezierArcLengthLutNode { + readonly parameter: number; + readonly point: DrillGridPoint; + readonly cumulativeLengthSteps: number; +} + +interface BezierArcLengthLut { + readonly nodes: readonly BezierArcLengthLutNode[]; + readonly lengthSteps: number; +} + +function resolvePathWithEndpoints( + document: DrillDocument, + entityId: number, + fromSetId: number, + toSetId: number, + start: DrillGridPoint, + end: DrillGridPoint, +): TransitionPathGeometry { + const explicitPath = document.paths?.find( + (path) => + path.entityId === entityId && + path.fromSetId === fromSetId && + path.toSetId === toSetId, + ); + + return pathToGeometry(explicitPath, start, end); +} + +function pathToGeometry( + path: DrillPath | undefined, + start: DrillGridPoint, + end: DrillGridPoint, +): TransitionPathGeometry { + if (!path || path.kind === "straight") { + return { + kind: "straight", + start: clonePoint(start), + end: clonePoint(end), + }; + } + + if (path.kind === "polyline") { + return { + kind: "polyline", + points: [ + clonePoint(start), + ...path.waypoints.map(clonePoint), + clonePoint(end), + ], + }; + } + + return { + kind: "bezier", + start: clonePoint(start), + controlPoints: [ + clonePoint(path.controlPoints[0]), + clonePoint(path.controlPoints[1]), + ], + end: clonePoint(end), + }; +} + +function findPosition( + document: DrillDocument, + entityId: number, + setId: number, +): DrillGridPoint | undefined { + const position = document.positions.find( + (candidate) => candidate.entityId === entityId && candidate.setId === setId, + ); + return position ? clonePoint(position) : undefined; +} + +function measurePolyline(points: readonly DrillGridPoint[]): { + readonly lengthSteps: number; + readonly midpoint: DrillGridPoint; +} { + if (points.length === 0) { + throw new RangeError("A polyline must contain at least one point."); + } + if (points.length === 1) { + return { lengthSteps: 0, midpoint: clonePoint(points[0]) }; + } + + const segmentLengths = points + .slice(1) + .map((point, index) => distanceBetween(points[index], point)); + const lengthSteps = segmentLengths.reduce((sum, length) => sum + length, 0); + if (lengthSteps === 0) { + return { lengthSteps, midpoint: clonePoint(points[0]) }; + } + + const targetDistance = lengthSteps / 2; + let distanceBeforeSegment = 0; + for (const [index, segmentLength] of segmentLengths.entries()) { + const distanceAtSegmentEnd = distanceBeforeSegment + segmentLength; + if ( + targetDistance <= distanceAtSegmentEnd || + index === segmentLengths.length - 1 + ) { + const ratio = + segmentLength === 0 + ? 0 + : (targetDistance - distanceBeforeSegment) / segmentLength; + return { + lengthSteps, + midpoint: interpolatePoint(points[index], points[index + 1], ratio), + }; + } + distanceBeforeSegment = distanceAtSegmentEnd; + } + + // The loop always returns for a finite, non-empty segment list. + return { lengthSteps, midpoint: clonePoint(points[points.length - 1]) }; +} + +function buildBezierArcLengthLut( + geometry: BezierTransitionGeometry, + options: NormalizedSubdivisionOptions, +): BezierArcLengthLut { + const nodes: { parameter: number; point: DrillGridPoint }[] = [ + { parameter: 0, point: clonePoint(geometry.start) }, + ]; + const [control1, control2] = geometry.controlPoints; + + subdivideBezier( + geometry.start, + control1, + control2, + geometry.end, + 0, + 1, + 0, + options, + nodes, + ); + + let cumulativeLengthSteps = 0; + const measuredNodes: BezierArcLengthLutNode[] = [ + { + parameter: nodes[0].parameter, + point: nodes[0].point, + cumulativeLengthSteps: 0, + }, + ]; + for (const node of nodes.slice(1)) { + cumulativeLengthSteps += distanceBetween( + measuredNodes[measuredNodes.length - 1].point, + node.point, + ); + measuredNodes.push({ + parameter: node.parameter, + point: node.point, + cumulativeLengthSteps, + }); + } + + return { nodes: measuredNodes, lengthSteps: cumulativeLengthSteps }; +} + +function subdivideBezier( + start: DrillGridPoint, + control1: DrillGridPoint, + control2: DrillGridPoint, + end: DrillGridPoint, + startParameter: number, + endParameter: number, + depth: number, + options: NormalizedSubdivisionOptions, + nodes: { parameter: number; point: DrillGridPoint }[], +): void { + const chordLength = distanceBetween(start, end); + const controlPolygonLength = + distanceBetween(start, control1) + + distanceBetween(control1, control2) + + distanceBetween(control2, end); + const flatness = Math.max(0, controlPolygonLength - chordLength); + + if (depth >= options.maxSubdivisionDepth || flatness <= options.tolerance) { + nodes.push({ parameter: endParameter, point: clonePoint(end) }); + return; + } + + const firstMidpoint = interpolatePoint(start, control1, 0.5); + const secondMidpoint = interpolatePoint(control1, control2, 0.5); + const thirdMidpoint = interpolatePoint(control2, end, 0.5); + const leftMidpoint = interpolatePoint(firstMidpoint, secondMidpoint, 0.5); + const rightMidpoint = interpolatePoint(secondMidpoint, thirdMidpoint, 0.5); + const curveMidpoint = interpolatePoint(leftMidpoint, rightMidpoint, 0.5); + const parameterMidpoint = (startParameter + endParameter) / 2; + + subdivideBezier( + start, + firstMidpoint, + leftMidpoint, + curveMidpoint, + startParameter, + parameterMidpoint, + depth + 1, + options, + nodes, + ); + subdivideBezier( + curveMidpoint, + rightMidpoint, + thirdMidpoint, + end, + parameterMidpoint, + endParameter, + depth + 1, + options, + nodes, + ); +} + +function locateBezierArcLengthPoint( + geometry: BezierTransitionGeometry, + lut: BezierArcLengthLut, +): { readonly point: DrillGridPoint; readonly parameter: number } { + if (lut.lengthSteps === 0) { + return { point: clonePoint(geometry.start), parameter: 0 }; + } + + const targetDistance = lut.lengthSteps / 2; + let segmentIndex = lut.nodes.length - 2; + for (let index = 0; index < lut.nodes.length - 1; index += 1) { + if ( + targetDistance <= lut.nodes[index + 1].cumulativeLengthSteps || + index === lut.nodes.length - 2 + ) { + segmentIndex = index; + break; + } + } + + const segmentStart = lut.nodes[segmentIndex]; + const segmentEnd = lut.nodes[segmentIndex + 1]; + const segmentLength = + segmentEnd.cumulativeLengthSteps - segmentStart.cumulativeLengthSteps; + if (segmentLength === 0) { + return { + point: clonePoint(segmentStart.point), + parameter: segmentStart.parameter, + }; + } + + // The LUT's cumulative distances are sums of flattened chord lengths. Use + // that exact same metric for inversion, including deliberately coarse LUTs. + const targetWithinSegment = + targetDistance - segmentStart.cumulativeLengthSteps; + const segmentRatio = targetWithinSegment / segmentLength; + const parameter = + segmentStart.parameter + + (segmentEnd.parameter - segmentStart.parameter) * segmentRatio; + return { + point: evaluateCubicGeometryAt(geometry, parameter), + parameter, + }; +} + +function evaluateCubicGeometryAt( + geometry: BezierTransitionGeometry, + parameter: number, +): DrillGridPoint { + return evaluateCubicBezier( + geometry.start, + geometry.controlPoints[0], + geometry.controlPoints[1], + geometry.end, + parameter, + ); +} + +function normalizeSubdivisionOptions( + options: TransitionGeometryOptions, +): NormalizedSubdivisionOptions { + const tolerance = + options.tolerance ?? DEFAULT_BEZIER_ARC_LENGTH_TOLERANCE_STEPS; + if (!Number.isFinite(tolerance) || tolerance <= 0) { + throw new RangeError( + "Bézier arc-length tolerance must be greater than zero.", + ); + } + + const maxSubdivisionDepth = + options.maxSubdivisionDepth ?? DEFAULT_BEZIER_MAX_SUBDIVISION_DEPTH; + if ( + !Number.isInteger(maxSubdivisionDepth) || + maxSubdivisionDepth < 0 || + maxSubdivisionDepth > MAX_BEZIER_SUBDIVISION_DEPTH + ) { + throw new RangeError( + `Bézier subdivision depth must be an integer from 0 to ${MAX_BEZIER_SUBDIVISION_DEPTH}.`, + ); + } + + return { tolerance, maxSubdivisionDepth }; +} + +function assertEpsilon(epsilon: number): void { + if (!Number.isFinite(epsilon) || epsilon < 0) { + throw new RangeError( + "Grid-point epsilon must be a finite non-negative number.", + ); + } +} + +function clonePoint(point: DrillGridPoint): DrillGridPoint { + return { xSteps: point.xSteps, ySteps: point.ySteps }; +} + +function distanceBetween(a: DrillGridPoint, b: DrillGridPoint): number { + return Math.hypot(b.xSteps - a.xSteps, b.ySteps - a.ySteps); +} + +function interpolatePoint( + start: DrillGridPoint, + end: DrillGridPoint, + ratio: number, +): DrillGridPoint { + return { + xSteps: start.xSteps + (end.xSteps - start.xSteps) * ratio, + ySteps: start.ySteps + (end.ySteps - start.ySteps) * ratio, + }; +} diff --git a/packages/mobile/src/drill/transition-scene.ts b/packages/mobile/src/drill/transition-scene.ts new file mode 100644 index 00000000..74afa414 --- /dev/null +++ b/packages/mobile/src/drill/transition-scene.ts @@ -0,0 +1,414 @@ +import type { DrillDocument, DrillGridPoint } from "@eight2five/drill-schema"; + +import { + areGridPointsEquivalent, + DEFAULT_GRID_POINT_EPSILON_STEPS, + resolveTransitionGeometry, + type ResolvedTransitionGeometry, + type TransitionGeometryOptions, + type TransitionPathGeometry, +} from "./transition-geometry"; + +/** Settings names mirror the persisted AppSettings contract. */ +export interface AppTransitionSceneSettings { + readonly showTransitionMarkers: boolean; + readonly showAllTransitionSets: boolean; + readonly previousTransitionSetCount: number; + readonly nextTransitionSetCount: number; +} + +/** A concise equivalent for callers that do not use the persisted settings object. */ +export interface TransitionSceneSettings { + readonly markerEnabled: boolean; + readonly showAll: boolean; + readonly previousTotalCount: number; + readonly nextTotalCount: number; +} + +export type TransitionSceneSettingsInput = + | AppTransitionSceneSettings + | TransitionSceneSettings; + +export interface TransitionSceneInput { + readonly document: DrillDocument; + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + readonly settings: TransitionSceneSettingsInput; + readonly geometryOptions?: TransitionGeometryOptions; + readonly epsilon?: number; +} + +export interface TransitionDot { + readonly setId: number; + readonly point: DrillGridPoint; +} + +export interface ImmediateTransition { + readonly entityId: number; + readonly fromSetId: number; + readonly toSetId: number; + readonly start: DrillGridPoint; + readonly end: DrillGridPoint; + /** The geometry used both for the connector and for midpoint calculation. */ + readonly geometry: TransitionPathGeometry; + readonly lengthSteps: number; + readonly midpoint: DrillGridPoint; + /** Only populated for cubic Bézier transitions. */ + readonly midpointParameter?: number; +} + +export interface TransitionScene { + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + /** Null means the selected performer has no position at the selected set. */ + readonly current: DrillGridPoint | null; + readonly previous?: ImmediateTransition; + readonly next?: ImmediateTransition; + /** Extra dots are ordered nearest-to-farthest from the selected set. */ + readonly previousDots: readonly TransitionDot[]; + readonly nextDots: readonly TransitionDot[]; +} + +/** + * Derive all transition marker geometry for one selected performer/set. + * + * Counts are ordinal windows: a count of one includes only the immediate + * neighbor, a count of two includes that neighbor and one extra set, and so + * on. Coincident suppression happens after selecting that raw window; omitted + * positions are never replaced by a farther set. + */ +export function buildTransitionScene( + input: TransitionSceneInput, +): TransitionScene; +export function buildTransitionScene( + document: DrillDocument, + selectedPerformerEntityId: number, + selectedSourceSetId: number, + settings: TransitionSceneSettingsInput, + geometryOptions?: TransitionGeometryOptions, +): TransitionScene; +export function buildTransitionScene( + inputOrDocument: TransitionSceneInput | DrillDocument, + selectedPerformerEntityId?: number, + selectedSourceSetId?: number, + settings?: TransitionSceneSettingsInput, + geometryOptions?: TransitionGeometryOptions, +): TransitionScene { + const input = isTransitionSceneInput(inputOrDocument) + ? inputOrDocument + : { + document: inputOrDocument, + selectedPerformerEntityId: selectedPerformerEntityId as number, + selectedSourceSetId: selectedSourceSetId as number, + settings: settings as TransitionSceneSettingsInput, + geometryOptions, + }; + const normalizedSettings = normalizeSettings(input.settings); + const epsilon = input.epsilon ?? DEFAULT_GRID_POINT_EPSILON_STEPS; + assertEpsilon(epsilon); + + const selectedSetIndex = input.document.sets.findIndex( + (set) => set.id === input.selectedSourceSetId, + ); + const currentPosition = positionAtIndex( + input.document, + input.selectedPerformerEntityId, + selectedSetIndex, + ); + const current = currentPosition?.point ?? null; + + const emptyScene: TransitionScene = { + selectedPerformerEntityId: input.selectedPerformerEntityId, + selectedSourceSetId: input.selectedSourceSetId, + current, + previousDots: [], + nextDots: [], + }; + if (!normalizedSettings.markerEnabled || selectedSetIndex < 0 || !current) { + return emptyScene; + } + + const previousIndices = rawWindowIndices( + selectedSetIndex, + input.document.sets.length, + "previous", + normalizedSettings.showAll, + normalizedSettings.previousTotalCount, + ); + const nextIndices = rawWindowIndices( + selectedSetIndex, + input.document.sets.length, + "next", + normalizedSettings.showAll, + normalizedSettings.nextTotalCount, + ); + + const previousPosition = positionAtIndex( + input.document, + input.selectedPerformerEntityId, + previousIndices[0], + ); + const nextPosition = positionAtIndex( + input.document, + input.selectedPerformerEntityId, + nextIndices[0], + ); + + const previous = createImmediateTransition( + input.document, + input.selectedPerformerEntityId, + previousPosition, + currentPosition, + input.geometryOptions, + epsilon, + ); + const next = createImmediateTransition( + input.document, + input.selectedPerformerEntityId, + currentPosition, + nextPosition, + input.geometryOptions, + epsilon, + ); + + const extraDots = createSceneExtraDots( + input.document, + input.selectedPerformerEntityId, + previousIndices.slice(1), + nextIndices.slice(1), + current, + previousPosition, + nextPosition, + epsilon, + ); + + return { + ...emptyScene, + ...(previous ? { previous } : {}), + ...(next ? { next } : {}), + previousDots: extraDots.previousDots, + nextDots: extraDots.nextDots, + }; +} + +export const deriveTransitionScene = buildTransitionScene; +export const buildTransitionMarkerScene = buildTransitionScene; + +function createImmediateTransition( + document: DrillDocument, + entityId: number, + fromPosition: PositionAtIndex | undefined, + toPosition: PositionAtIndex | undefined, + geometryOptions: TransitionGeometryOptions | undefined, + epsilon: number, +): ImmediateTransition | undefined { + if (!fromPosition || !toPosition) return undefined; + if (areGridPointsEquivalent(fromPosition.point, toPosition.point, epsilon)) { + return undefined; + } + + const resolved = resolveTransitionGeometry( + document, + entityId, + fromPosition.setId, + toPosition.setId, + geometryOptions, + ); + return resolved + ? makeImmediateTransition( + entityId, + fromPosition.setId, + toPosition.setId, + resolved, + ) + : undefined; +} + +function makeImmediateTransition( + entityId: number, + fromSetId: number, + toSetId: number, + resolved: ResolvedTransitionGeometry, +): ImmediateTransition { + const { geometry } = resolved; + const start = geometryStart(geometry); + const end = geometryEnd(geometry); + return { + entityId, + fromSetId, + toSetId, + start, + end, + geometry, + lengthSteps: resolved.lengthSteps, + midpoint: resolved.midpoint, + ...(resolved.midpointParameter === undefined + ? {} + : { midpointParameter: resolved.midpointParameter }), + }; +} + +function createSceneExtraDots( + document: DrillDocument, + entityId: number, + previousExtraIndices: readonly number[], + nextExtraIndices: readonly number[], + current: DrillGridPoint, + previousImmediatePosition: PositionAtIndex | undefined, + nextImmediatePosition: PositionAtIndex | undefined, + epsilon: number, +): { + readonly previousDots: readonly TransitionDot[]; + readonly nextDots: readonly TransitionDot[]; +} { + // Previous extras have deterministic priority, followed by next extras. + // Immediate markers are deliberately not part of `emittedPoints`: even + // coincident previous and next transitions remain semantically distinct. + const emittedPoints: DrillGridPoint[] = []; + const blockedPoints = [ + current, + ...(previousImmediatePosition ? [previousImmediatePosition.point] : []), + ...(nextImmediatePosition ? [nextImmediatePosition.point] : []), + ]; + + const emit = ( + rawExtraIndices: readonly number[], + ): readonly TransitionDot[] => { + const dots: TransitionDot[] = []; + for (const index of rawExtraIndices) { + const position = positionAtIndex(document, entityId, index); + if (!position) continue; + if ( + blockedPoints.some((blocked) => + areGridPointsEquivalent(blocked, position.point, epsilon), + ) || + emittedPoints.some((emitted) => + areGridPointsEquivalent(emitted, position.point, epsilon), + ) + ) { + continue; + } + dots.push({ setId: position.setId, point: position.point }); + emittedPoints.push(position.point); + } + return dots; + }; + + return { + previousDots: emit(previousExtraIndices), + nextDots: emit(nextExtraIndices), + }; +} + +function rawWindowIndices( + selectedSetIndex: number, + setCount: number, + direction: "previous" | "next", + showAll: boolean, + totalCount: number, +): readonly number[] { + const availableCount = + direction === "previous" + ? selectedSetIndex + : setCount - selectedSetIndex - 1; + const windowCount = showAll + ? availableCount + : Math.min(totalCount, availableCount); + return Array.from({ length: windowCount }, (_, offset) => + direction === "previous" + ? selectedSetIndex - offset - 1 + : selectedSetIndex + offset + 1, + ); +} + +interface PositionAtIndex { + readonly setId: number; + readonly point: DrillGridPoint; +} + +function positionAtIndex( + document: DrillDocument, + entityId: number, + index: number | undefined, +): PositionAtIndex | undefined { + if (index === undefined || index < 0 || index >= document.sets.length) { + return undefined; + } + const setId = document.sets[index].id; + const point = findPosition(document, entityId, setId); + return point ? { setId, point } : undefined; +} + +function findPosition( + document: DrillDocument, + entityId: number, + setId: number, +): DrillGridPoint | null { + const position = document.positions.find( + (candidate) => candidate.entityId === entityId && candidate.setId === setId, + ); + return position ? { xSteps: position.xSteps, ySteps: position.ySteps } : null; +} + +function geometryStart(geometry: TransitionPathGeometry): DrillGridPoint { + return geometry.kind === "polyline" ? geometry.points[0] : geometry.start; +} + +function geometryEnd(geometry: TransitionPathGeometry): DrillGridPoint { + return geometry.kind === "polyline" + ? geometry.points[geometry.points.length - 1] + : geometry.end; +} + +function normalizeSettings( + settings: TransitionSceneSettingsInput, +): NormalizedTransitionSceneSettings { + if ("showTransitionMarkers" in settings) { + assertCount(settings.previousTransitionSetCount, "previous"); + assertCount(settings.nextTransitionSetCount, "next"); + return { + markerEnabled: settings.showTransitionMarkers, + showAll: settings.showAllTransitionSets, + previousTotalCount: settings.previousTransitionSetCount, + nextTotalCount: settings.nextTransitionSetCount, + }; + } + + assertCount(settings.previousTotalCount, "previous"); + assertCount(settings.nextTotalCount, "next"); + return { + markerEnabled: settings.markerEnabled, + showAll: settings.showAll, + previousTotalCount: settings.previousTotalCount, + nextTotalCount: settings.nextTotalCount, + }; +} + +interface NormalizedTransitionSceneSettings { + readonly markerEnabled: boolean; + readonly showAll: boolean; + readonly previousTotalCount: number; + readonly nextTotalCount: number; +} + +function assertCount(value: number, direction: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new RangeError( + `${direction} transition total count must be a non-negative integer.`, + ); + } +} + +function assertEpsilon(epsilon: number): void { + if (!Number.isFinite(epsilon) || epsilon < 0) { + throw new RangeError( + "Grid-point epsilon must be a finite non-negative number.", + ); + } +} + +function isTransitionSceneInput( + value: TransitionSceneInput | DrillDocument, +): value is TransitionSceneInput { + return "document" in value && "settings" in value; +} From c4855f7e0246e2047ebb1f79f985f3c51bc2f559 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 14:03:23 -0500 Subject: [PATCH 069/101] feat(field): render drill entities and transition markers --- .../src/features/field/field-screen.tsx | 27 +- .../field/use-field-screen-controller.ts | 71 ++- .../__tests__/settings-screen-policy.test.ts | 8 + .../settings/settings-screen-policy.ts | 3 + .../src/features/settings/settings-screen.tsx | 97 ++++ .../src/drill/__tests__/render-scene.test.ts | 380 +++++++++++++ packages/mobile/src/drill/index.ts | 2 + .../src/drill/physical-transition-geometry.ts | 378 +++++++++++++ packages/mobile/src/drill/render-scene.ts | 502 ++++++++++++++++++ .../__tests__/drill-shape-policy.test.ts | 72 +++ .../__tests__/field-overlay-policy.test.ts | 55 ++ .../src/field/render/drill-shape-policy.ts | 168 ++++++ .../mobile/src/field/render/field-canvas.tsx | 4 + .../src/field/render/field-drill-layer.tsx | 456 ++++++++++++++++ .../src/field/render/field-overlay-types.ts | 58 +- .../src/field/render/field-position-layer.tsx | 25 +- .../src/field/render/field-render-tokens.ts | 28 +- .../mobile/src/field/render/field-scene.tsx | 11 +- packages/mobile/src/field/render/index.ts | 2 + packages/mobile/src/index.ts | 1 + 20 files changed, 2310 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts create mode 100644 apps/mobile/src/features/settings/settings-screen-policy.ts create mode 100644 packages/mobile/src/drill/__tests__/render-scene.test.ts create mode 100644 packages/mobile/src/drill/physical-transition-geometry.ts create mode 100644 packages/mobile/src/drill/render-scene.ts create mode 100644 packages/mobile/src/field/__tests__/drill-shape-policy.test.ts create mode 100644 packages/mobile/src/field/render/drill-shape-policy.ts create mode 100644 packages/mobile/src/field/render/field-drill-layer.tsx diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index a038ede9..602218b6 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -2,8 +2,9 @@ import React from "react"; import { EMPTY_FIELD_LIVE_POSITION_STATE, drillGridPointToFieldPoint, - shouldShowFieldGuidance, + shouldShowFieldGuidanceForScene, shouldShowFieldTarget, + resolveCurrentTargetPosition, type FieldAnchorGeometry, type FieldAnchorOverlayOptions, type FieldLivePositionInput, @@ -71,13 +72,31 @@ export function FieldScreen({ hasLivePosition: Boolean(liveState.position) && !liveState.isStale, guidanceEnabled: controller.settings.guidanceEnabled, }; - const targetPosition = + const fallbackTargetPosition = shouldShowFieldTarget(drillOverlayState) && controller.selectedPage ? drillGridPointToFieldPoint( controller.selectedPage.position, controller.fieldPreset, ) : undefined; + const drillScene = controller.settings.drillFeaturesEnabled + ? controller.drillScene + : undefined; + const targetPolicy = { + fullDrillSceneAvailable: drillScene !== undefined, + sceneHasCurrent: + drillScene?.current !== undefined && drillScene?.current !== null, + legacyFallbackAvailable: fallbackTargetPosition !== undefined, + } as const; + const targetPosition = resolveCurrentTargetPosition({ + fullDrillSceneAvailable: targetPolicy.fullDrillSceneAvailable, + sceneCurrent: drillScene?.current, + legacyFallback: fallbackTargetPosition, + }); + const guidanceVisible = shouldShowFieldGuidanceForScene( + drillOverlayState, + targetPolicy, + ); const palette = React.useMemo( () => ({ canvasBackground: theme.background, @@ -87,7 +106,6 @@ export function FieldScreen({ fieldLines: theme.textMuted, fieldNumbers: theme.textMuted, livePosition: theme.accent, - target: "#D29B22", guidance: theme.accent, anchor: theme.accent, anchorRange: colorWithAlpha(theme.accent, "24"), @@ -108,7 +126,8 @@ export function FieldScreen({ fieldPreset={controller.fieldPreset} livePosition={livePositionValue} targetPosition={targetPosition} - guidanceVisible={shouldShowFieldGuidance(drillOverlayState)} + drillScene={drillScene} + guidanceVisible={guidanceVisible} anchors={anchors} anchorOverlayOptions={anchorOverlayOptions} showAuxiliaryFieldMarks={controller.settings.showAuxiliaryFieldMarks} diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts index 00df6899..46bd8c13 100644 --- a/apps/mobile/src/features/field/use-field-screen-controller.ts +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -2,7 +2,15 @@ import React from "react"; import { useFocusEffect } from "expo-router"; import { useWindowDimensions } from "react-native"; import type { FieldViewport } from "@eight2five/mobile/field"; -import type { Drill, DrillSet } from "@eight2five/mobile/drill"; +import { + buildDrillRenderScene, + resolveSelectedSourceSetId, + shouldBuildDrillRenderScene, + type Drill, + type DrillDocument, + type DrillSet, + type DrillRenderScene, +} from "@eight2five/mobile/drill"; import { useFieldOrientation } from "../../navigation/use-field-orientation"; import { @@ -26,6 +34,8 @@ export function useFieldScreenController() { const [initialViewport] = React.useState(() => committedFieldViewport); const [drills, setDrills] = React.useState([]); const [activeDrill, setActiveDrill] = React.useState(); + const [activeDrillDocument, setActiveDrillDocument] = + React.useState(); const [pages, setPages] = React.useState([]); const [loadingDrills, setLoadingDrills] = React.useState(true); const [fieldError, setFieldError] = React.useState(); @@ -47,14 +57,19 @@ export function useFieldScreenController() { try { const repository = store.getDrillRepository(); const activeDrillId = snapshot.settings.activeDrillId; - const [nextDrills, nextActiveDrill, nextPages] = await Promise.all([ - repository.listDrills(), - activeDrillId ? repository.getDrill(activeDrillId) : undefined, - activeDrillId ? repository.listSets(activeDrillId) : [], - ]); + const [nextDrills, nextActiveDrill, nextPages, nextDocument] = + await Promise.all([ + repository.listDrills(), + activeDrillId ? repository.getDrill(activeDrillId) : undefined, + activeDrillId ? repository.listSets(activeDrillId) : [], + activeDrillId + ? repository.getDrillDocument(activeDrillId) + : undefined, + ]); if (generation !== refreshGeneration.current) return; setDrills(nextDrills); setActiveDrill(nextActiveDrill); + setActiveDrillDocument(nextDocument); setPages(nextPages); setFieldError(undefined); } catch (cause) { @@ -142,6 +157,48 @@ export function useFieldScreenController() { activeDrill, snapshot.settings.defaultFieldPreset, ); + const selectedSourceSetId = resolveSelectedSourceSetId(selectedPage); + const selectedPerformerEntityId = activeDrill?.selectedPerformerEntityId; + const drillScene = React.useMemo(() => { + if ( + !shouldBuildDrillRenderScene(snapshot.settings.drillFeaturesEnabled) || + !activeDrillDocument || + selectedSourceSetId === undefined || + selectedPerformerEntityId === undefined + ) { + return undefined; + } + return buildDrillRenderScene({ + document: activeDrillDocument, + field: fieldPreset, + selectedPerformerEntityId, + selectedSourceSetId, + settings: { + showPerformerLabels: snapshot.settings.showPerformerLabels, + showPerformerNames: snapshot.settings.showPerformerNames, + showPropLabels: snapshot.settings.showPropLabels, + showPropNames: snapshot.settings.showPropNames, + markerEnabled: snapshot.settings.showTransitionMarkers, + showAll: snapshot.settings.showAllTransitionSets, + previousTotalCount: snapshot.settings.previousTransitionSetCount, + nextTotalCount: snapshot.settings.nextTransitionSetCount, + }, + }); + }, [ + activeDrillDocument, + fieldPreset, + selectedPerformerEntityId, + selectedSourceSetId, + snapshot.settings.nextTransitionSetCount, + snapshot.settings.previousTransitionSetCount, + snapshot.settings.drillFeaturesEnabled, + snapshot.settings.showAllTransitionSets, + snapshot.settings.showPerformerLabels, + snapshot.settings.showPerformerNames, + snapshot.settings.showPropLabels, + snapshot.settings.showPropNames, + snapshot.settings.showTransitionMarkers, + ]); return { width, @@ -153,6 +210,8 @@ export function useFieldScreenController() { settings: snapshot.settings, drills, activeDrill, + activeDrillDocument, + drillScene, pages, selectedIndex, selectedPage, diff --git a/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts b/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts new file mode 100644 index 00000000..e5830a05 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts @@ -0,0 +1,8 @@ +import { shouldShowTransitionCountControls } from "../settings-screen-policy"; + +describe("settings screen transition count policy", () => { + test("hides detailed count controls when Show all is enabled", () => { + expect(shouldShowTransitionCountControls(true)).toBe(false); + expect(shouldShowTransitionCountControls(false)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/settings/settings-screen-policy.ts b/apps/mobile/src/features/settings/settings-screen-policy.ts new file mode 100644 index 00000000..a4f91914 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-screen-policy.ts @@ -0,0 +1,3 @@ +export function shouldShowTransitionCountControls(showAll: boolean): boolean { + return !showAll; +} diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 512504e1..46ea5ce3 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -33,6 +33,7 @@ import { } from "../../state/app-settings-store"; import { ResetSettingsControl } from "./reset-settings-control"; import { updateDrillFeatures } from "./settings-actions"; +import { shouldShowTransitionCountControls } from "./settings-screen-policy"; import { SettingsMessage, SettingsNavigationRow, @@ -69,6 +70,11 @@ const TRANSITION_CHOICES = [ { label: "xCounts", value: "crossing-counts" }, ] as const; +const TRANSITION_COUNT_CHOICES = Array.from({ length: 51 }, (_, count) => ({ + label: String(count), + value: String(count), +})); + export function SettingsScreen() { const router = useRouter(); const store = useAppSettingsStore(); @@ -207,6 +213,97 @@ export function SettingsScreen() { disabled={disabled} testID="transition-metric-setting" /> + + void update({ showTransitionMarkers }) + } + disabled={disabled} + testID="show-transition-markers-setting" + /> + + void update({ showAllTransitionSets }) + } + disabled={disabled} + testID="show-all-transition-sets-setting" + /> + {shouldShowTransitionCountControls(settings.showAllTransitionSets) ? ( + <> + + icon={Route} + title="Previous transition positions" + description="Total positions, including the immediate previous marker." + value={String(settings.previousTransitionSetCount)} + choices={TRANSITION_COUNT_CHOICES} + onChange={(value) => + void update({ previousTransitionSetCount: Number(value) }) + } + disabled={disabled} + testID="previous-transition-set-count-setting" + /> + + icon={Route} + title="Next transition positions" + description="Total positions, including the immediate next marker." + value={String(settings.nextTransitionSetCount)} + choices={TRANSITION_COUNT_CHOICES} + onChange={(value) => + void update({ nextTransitionSetCount: Number(value) }) + } + disabled={disabled} + testID="next-transition-set-count-setting" + /> + + ) : null} + + + + + void update({ showPerformerLabels }) + } + disabled={disabled} + testID="show-performer-labels-setting" + /> + void update({ showPerformerNames })} + disabled={disabled} + testID="show-performer-names-setting" + /> + void update({ showPropLabels })} + disabled={disabled} + testID="show-prop-labels-setting" + /> + void update({ showPropNames })} + disabled={disabled} + testID="show-prop-names-setting" + /> diff --git a/packages/mobile/src/drill/__tests__/render-scene.test.ts b/packages/mobile/src/drill/__tests__/render-scene.test.ts new file mode 100644 index 00000000..75abaf11 --- /dev/null +++ b/packages/mobile/src/drill/__tests__/render-scene.test.ts @@ -0,0 +1,380 @@ +import { + DRILL_MARKER_COLORS, + DRILL_MARKER_SIZE_METERS, + DRILL_MARKER_SIZE_STEPS, +} from "../../field/render/field-render-tokens"; +import { + buildDrillRenderScene, + DEFAULT_PERFORMER_DIAMETER_METERS, + DRILL_RENDER_LAYER_ORDER, + projectTransitionPathGeometry, + resolveSelectedSourceSetId, + resolvePropPhysicalSize, + shouldBuildDrillRenderScene, + type DrillRenderSceneSettings, +} from "../render-scene"; +import { measurePhysicalTransitionGeometry } from "../physical-transition-geometry"; +import { buildTransitionScene } from "../transition-scene"; +import type { DrillDocument, FieldDefinition } from "@eight2five/drill-schema"; +import { + COLOR_PRESETS, + drillGridToPhysicalPoint, + getFieldPreset, +} from "@eight2five/drill-schema"; + +const SETTINGS: DrillRenderSceneSettings = { + showPerformerLabels: true, + showPerformerNames: true, + showPropLabels: true, + showPropNames: true, + markerEnabled: true, + showAll: false, + previousTotalCount: 2, + nextTotalCount: 2, +}; + +const DOCUMENT: DrillDocument = { + schema: "https://eight2five.com/schema/drill", + schemaVersion: "2.0.0", + metadata: { + title: "Entity render fixture", + createdAt: "2026-01-01T00:00:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entityRules: { + bySymbol: { + performer: { appearance: { color: "#101010", icon: "diamond" } }, + }, + byLabel: { + Flag: { + size: { length: 2, width: 1, unit: "feet" }, + appearance: { color: "#123456", icon: "square" }, + }, + }, + }, + entities: [ + { + id: 1, + type: "performer", + symbol: "performer", + label: "A", + name: "Alice", + }, + { + id: 2, + type: "performer", + symbol: "performer", + label: "B", + name: "Bob", + }, + { + id: 3, + type: "prop", + symbol: "prop", + label: "Flag", + name: "Blue flag", + }, + ], + sets: [ + { + id: 10, + number: 1, + kind: "set", + countsFromPrevious: 0, + }, + { + id: 11, + number: 2, + kind: "set", + countsFromPrevious: 8, + }, + { + id: 12, + number: 3, + kind: "set", + countsFromPrevious: 8, + }, + ], + positions: [ + { entityId: 1, setId: 10, xSteps: 0, ySteps: 0 }, + { entityId: 1, setId: 11, xSteps: 8, ySteps: 8 }, + { entityId: 1, setId: 12, xSteps: 16, ySteps: 8 }, + { entityId: 2, setId: 11, xSteps: 10, ySteps: 30 }, + { entityId: 3, setId: 11, xSteps: -10, ySteps: 20 }, + ], + paths: [ + { + entityId: 1, + fromSetId: 10, + toSetId: 11, + kind: "polyline", + waypoints: [{ xSteps: 4, ySteps: 1 }], + }, + ], +}; + +const NON_UNIFORM_CUSTOM_FIELD: FieldDefinition = { + type: "custom", + name: "Non-uniform test field", + physicalGeometry: { + bounds: { + minXMeters: -10, + maxXMeters: 10, + minYMeters: 0, + maxYMeters: 168, + }, + referenceLines: [ + { id: "x-min", name: "X minimum", axis: "x", coordinateMeters: -10 }, + { id: "x-max", name: "X maximum", axis: "x", coordinateMeters: 10 }, + { id: "y-front", name: "Y front", axis: "y", coordinateMeters: 0 }, + { id: "y-back", name: "Y back", axis: "y", coordinateMeters: 168 }, + ], + }, + marchingGrid: { + bounds: { + minXSteps: -10, + maxXSteps: 10, + minYSteps: 0, + maxYSteps: 84, + }, + referenceLines: [ + { id: "x-min", name: "X minimum", axis: "x", coordinateSteps: -10 }, + { id: "x-max", name: "X maximum", axis: "x", coordinateSteps: 10 }, + { id: "y-front", name: "Y front", axis: "y", coordinateSteps: 0 }, + { id: "y-back", name: "Y back", axis: "y", coordinateSteps: 84 }, + ], + }, + markings: { + yardNumbers: { + heightMeters: 0, + nominalWidthMeters: 0, + centerFromFrontSidelineMeters: 0, + centerFromBackSidelineMeters: 0, + }, + inboundsHashMarks: { lengthMeters: 0, spacingMeters: 1 }, + sidelineHashMarks: { + lengthMeters: 0, + spacingMeters: 1, + insetFromSidelineMeters: 0, + }, + }, +}; + +describe("selected-set drill render scene", () => { + test("uses the drill feature master switch as a scene creation policy", () => { + expect(shouldBuildDrillRenderScene(true)).toBe(true); + expect(shouldBuildDrillRenderScene(false)).toBe(false); + }); + + test("maps an opaque local set row to its nontrivial portable source set id", () => { + expect( + resolveSelectedSourceSetId({ id: "sqlite-set-900", sourceSetId: 11 }), + ).toBe(11); + expect(resolveSelectedSourceSetId({ id: "manual-set" })).toBeUndefined(); + }); + + test("resolves every ordinary entity, projects it, and keeps the selected performer in the target layer", () => { + const scene = buildDrillRenderScene({ + document: DOCUMENT, + field: "football-nfhs", + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + const performer = scene.entities.find((entity) => entity.entityId === 2); + const prop = scene.entities.find((entity) => entity.entityId === 3); + + expect(scene.entities.map((entity) => entity.entityId)).toEqual([2, 3]); + expect(performer).toMatchObject({ + type: "performer", + diameterMeters: DEFAULT_PERFORMER_DIAMETER_METERS, + color: "#101010", + icon: "diamond", + labelText: "B", + nameText: "Bob", + opacity: 1, + }); + expect(prop).toMatchObject({ + type: "prop", + widthMeters: 0.3048, + lengthMeters: 0.6096, + color: "#123456", + icon: "square", + labelText: "Flag", + nameText: "Blue flag", + opacity: 1, + }); + expect(scene.current).toEqual(physicalPoint({ xSteps: 8, ySteps: 8 })); + expect(scene.previous?.geometry.kind).toBe("polyline"); + expect(scene.next?.geometry.kind).toBe("straight"); + expect(scene.previous?.midpoint).toEqual( + measurePhysicalTransitionGeometry(scene.previous!.geometry).midpoint, + ); + expect(scene.previous?.geometry).toMatchObject({ + points: [ + physicalPoint({ xSteps: 0, ySteps: 0 }), + physicalPoint({ xSteps: 4, ySteps: 1 }), + physicalPoint({ xSteps: 8, ySteps: 8 }), + ], + }); + expect(Object.isFrozen(scene)).toBe(true); + expect(Object.isFrozen(scene.entities)).toBe(true); + }); + + test("keeps labels and names independent and marker master only suppresses transition graphics", () => { + const scene = buildDrillRenderScene({ + document: DOCUMENT, + field: "football-nfhs", + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: { + ...SETTINGS, + showPerformerLabels: false, + showPropLabels: false, + markerEnabled: false, + }, + }); + + expect(scene.entities[0].labelText).toBeUndefined(); + expect(scene.entities[0].nameText).toBe("Bob"); + expect(scene.entities[1].labelText).toBeUndefined(); + expect(scene.entities[1].nameText).toBe("Blue flag"); + expect(scene.current).not.toBeNull(); + expect(scene.previous).toBeUndefined(); + expect(scene.next).toBeUndefined(); + expect(scene.previousDots).toEqual([]); + expect(scene.nextDots).toEqual([]); + }); + + test("does not invent a selected target or transitions when its source position is missing", () => { + const documentWithoutCurrentPosition: DrillDocument = { + ...DOCUMENT, + positions: DOCUMENT.positions.filter( + (position) => !(position.entityId === 1 && position.setId === 11), + ), + }; + const scene = buildDrillRenderScene({ + document: documentWithoutCurrentPosition, + field: "football-nfhs", + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + + expect(scene.current).toBeNull(); + expect(scene.previous).toBeUndefined(); + expect(scene.next).toBeUndefined(); + expect(scene.previousDots).toEqual([]); + expect(scene.nextDots).toEqual([]); + }); + + test("measures midpoint from the projected connector geometry on a nonlinear field", () => { + const documentWithCurve: DrillDocument = { + ...DOCUMENT, + paths: [ + { + entityId: 1, + fromSetId: 10, + toSetId: 11, + kind: "bezier", + controlPoints: [ + { xSteps: 0, ySteps: 30 }, + { xSteps: 8, ySteps: 30 }, + ], + }, + ], + }; + const scene = buildDrillRenderScene({ + document: documentWithCurve, + field: NON_UNIFORM_CUSTOM_FIELD, + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + const previous = scene.previous; + expect(previous?.geometry.kind).toBe("bezier"); + if (!previous || previous.geometry.kind !== "bezier") return; + + const measured = measurePhysicalTransitionGeometry(previous.geometry); + expect(previous.midpoint).toEqual(measured.midpoint); + expect(previous.midpointParameter).toBe(measured.midpointParameter); + + const gridScene = buildTransitionScene({ + document: documentWithCurve, + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + expect(previous.lengthSteps).toBe(gridScene.previous?.lengthSteps); + const projectedGridMidpoint = drillGridToPhysicalPoint( + gridScene.previous!.midpoint, + NON_UNIFORM_CUSTOM_FIELD, + ); + expect(previous.midpoint).not.toEqual(projectedGridMidpoint); + }); + + test("projects path controls and waypoints through the active field preset", () => { + const geometry = projectTransitionPathGeometry( + { + kind: "bezier", + start: { xSteps: -8, ySteps: 4 }, + controlPoints: [ + { xSteps: -2, ySteps: 12 }, + { xSteps: 4, ySteps: 16 }, + ], + end: { xSteps: 8, ySteps: 20 }, + }, + "football-ncaa", + ); + + expect(geometry).toEqual({ + kind: "bezier", + start: physicalPoint({ xSteps: -8, ySteps: 4 }, "football-ncaa"), + controlPoints: [ + physicalPoint({ xSteps: -2, ySteps: 12 }, "football-ncaa"), + physicalPoint({ xSteps: 4, ySteps: 16 }, "football-ncaa"), + ], + end: physicalPoint({ xSteps: 8, ySteps: 20 }, "football-ncaa"), + }); + }); + + test("converts prop sizes and keeps marker size math explicit", () => { + expect( + resolvePropPhysicalSize({ + size: { length: 1, width: 2, unit: "meters" }, + }), + ).toEqual({ widthMeters: 2, lengthMeters: 1 }); + expect(DEFAULT_PERFORMER_DIAMETER_METERS).toBe(0.5715); + expect(DRILL_MARKER_SIZE_STEPS).toEqual({ + currentDiameter: 1.5, + transitionDiameter: 0.75, + midpointDiameter: 0.375, + }); + expect(DRILL_MARKER_SIZE_METERS.currentDiameter).toBeCloseTo(0.85725); + expect(DRILL_MARKER_SIZE_METERS.transitionDiameter).toBeCloseTo(0.428625); + expect(DRILL_MARKER_SIZE_METERS.midpointDiameter).toBeCloseTo(0.2143125); + expect(DRILL_MARKER_COLORS).toEqual({ + yellow: COLOR_PRESETS.yellow, + red: COLOR_PRESETS.red, + green: COLOR_PRESETS.green, + }); + expect(DRILL_RENDER_LAYER_ORDER).toEqual([ + "static", + "anchors", + "entities", + "extra-dots", + "previous", + "next", + "current-target", + "guidance", + "live-position", + ]); + }); +}); + +function physicalPoint( + point: { readonly xSteps: number; readonly ySteps: number }, + preset: "football-nfhs" | "football-ncaa" = "football-nfhs", +) { + return drillGridToPhysicalPoint(point, getFieldPreset(preset)); +} diff --git a/packages/mobile/src/drill/index.ts b/packages/mobile/src/drill/index.ts index 92abfef2..ccdfbc5c 100644 --- a/packages/mobile/src/drill/index.ts +++ b/packages/mobile/src/drill/index.ts @@ -11,4 +11,6 @@ export * from "./terminology"; export * from "./analysis"; export * from "./transition-geometry"; export * from "./transition-scene"; +export * from "./render-scene"; +export * from "./physical-transition-geometry"; export * from "./SqliteDrillRepository"; diff --git a/packages/mobile/src/drill/physical-transition-geometry.ts b/packages/mobile/src/drill/physical-transition-geometry.ts new file mode 100644 index 00000000..ad1016be --- /dev/null +++ b/packages/mobile/src/drill/physical-transition-geometry.ts @@ -0,0 +1,378 @@ +import type { + PhysicalBezierTransitionGeometry, + PhysicalImmediateTransition, + PhysicalTransitionPathGeometry, +} from "./render-scene"; +import type { PhysicalFieldPoint } from "@eight2five/drill-schema"; +import { + DEFAULT_BEZIER_ARC_LENGTH_TOLERANCE_STEPS, + DEFAULT_BEZIER_MAX_SUBDIVISION_DEPTH, + MAX_BEZIER_SUBDIVISION_DEPTH, + type TransitionGeometryOptions, +} from "./transition-geometry"; +import { STANDARD_STEP_METERS } from "../field/units"; + +/** Measured geometry in the same physical coordinate space sent to Skia. */ +export interface MeasuredPhysicalTransitionGeometry { + readonly geometry: PhysicalTransitionPathGeometry; + readonly lengthMeters: number; + readonly midpoint: PhysicalFieldPoint; + readonly midpointParameter?: number; +} + +/** + * Measure the projected geometry, rather than projecting a midpoint measured in + * grid coordinates. This matters for custom fields whose X/Y scales are not + * uniform: the connector and midpoint must use one physical path metric. + */ +export function measurePhysicalTransitionGeometry( + geometry: PhysicalTransitionPathGeometry, + options: TransitionGeometryOptions = {}, +): MeasuredPhysicalTransitionGeometry { + switch (geometry.kind) { + case "straight": { + const lengthMeters = physicalDistance(geometry.start, geometry.end); + return { + geometry, + lengthMeters, + midpoint: interpolatePhysicalPoint(geometry.start, geometry.end, 0.5), + }; + } + case "polyline": { + const measured = measurePhysicalPolyline(geometry.points); + return { + geometry, + lengthMeters: measured.lengthMeters, + midpoint: measured.midpoint, + }; + } + case "bezier": { + const lut = buildPhysicalBezierArcLengthLut( + geometry, + normalizePhysicalBezierOptions(options), + ); + const midpoint = locatePhysicalBezierArcLengthPoint(geometry, lut); + return { + geometry, + lengthMeters: lut.lengthMeters, + midpoint: midpoint.point, + midpointParameter: midpoint.parameter, + }; + } + } +} + +/** Keep Phase 3's semantic grid length while replacing only projected geometry. */ +export function withPhysicalTransitionMidpoint( + transition: Omit< + PhysicalImmediateTransition, + "midpoint" | "midpointParameter" + >, + geometry: PhysicalTransitionPathGeometry, + options: TransitionGeometryOptions = {}, +): PhysicalImmediateTransition { + const measured = measurePhysicalTransitionGeometry(geometry, options); + return Object.freeze({ + ...transition, + geometry: measured.geometry, + midpoint: measured.midpoint, + ...(measured.midpointParameter === undefined + ? {} + : { midpointParameter: measured.midpointParameter }), + }); +} + +interface NormalizedPhysicalBezierOptions { + readonly toleranceMeters: number; + readonly maxSubdivisionDepth: number; +} + +interface PhysicalBezierArcLengthLutNode { + readonly parameter: number; + readonly point: PhysicalFieldPoint; + readonly cumulativeLengthMeters: number; +} + +interface PhysicalBezierArcLengthLut { + readonly nodes: readonly PhysicalBezierArcLengthLutNode[]; + readonly lengthMeters: number; +} + +function measurePhysicalPolyline(points: readonly PhysicalFieldPoint[]): { + readonly lengthMeters: number; + readonly midpoint: PhysicalFieldPoint; +} { + if (points.length === 0) { + throw new RangeError("A polyline must contain at least one point."); + } + if (points.length === 1) { + return { lengthMeters: 0, midpoint: clonePhysicalPoint(points[0]) }; + } + + const segmentLengths = points + .slice(1) + .map((point, index) => physicalDistance(points[index], point)); + const lengthMeters = segmentLengths.reduce((sum, length) => sum + length, 0); + if (lengthMeters === 0) { + return { lengthMeters, midpoint: clonePhysicalPoint(points[0]) }; + } + + const targetDistance = lengthMeters / 2; + let distanceBeforeSegment = 0; + for (const [index, segmentLength] of segmentLengths.entries()) { + const distanceAtSegmentEnd = distanceBeforeSegment + segmentLength; + if ( + targetDistance <= distanceAtSegmentEnd || + index === segmentLengths.length - 1 + ) { + const ratio = + segmentLength === 0 + ? 0 + : (targetDistance - distanceBeforeSegment) / segmentLength; + return { + lengthMeters, + midpoint: interpolatePhysicalPoint( + points[index], + points[index + 1], + ratio, + ), + }; + } + distanceBeforeSegment = distanceAtSegmentEnd; + } + + return { + lengthMeters, + midpoint: clonePhysicalPoint(points[points.length - 1]), + }; +} + +function buildPhysicalBezierArcLengthLut( + geometry: PhysicalBezierTransitionGeometry, + options: NormalizedPhysicalBezierOptions, +): PhysicalBezierArcLengthLut { + const nodes: { parameter: number; point: PhysicalFieldPoint }[] = [ + { parameter: 0, point: clonePhysicalPoint(geometry.start) }, + ]; + subdividePhysicalBezier( + geometry.start, + geometry.controlPoints[0], + geometry.controlPoints[1], + geometry.end, + 0, + 1, + 0, + options, + nodes, + ); + + let cumulativeLengthMeters = 0; + const measuredNodes: PhysicalBezierArcLengthLutNode[] = [ + { + parameter: nodes[0].parameter, + point: nodes[0].point, + cumulativeLengthMeters: 0, + }, + ]; + for (const node of nodes.slice(1)) { + cumulativeLengthMeters += physicalDistance( + measuredNodes[measuredNodes.length - 1].point, + node.point, + ); + measuredNodes.push({ + parameter: node.parameter, + point: node.point, + cumulativeLengthMeters, + }); + } + return { nodes: measuredNodes, lengthMeters: cumulativeLengthMeters }; +} + +function subdividePhysicalBezier( + start: PhysicalFieldPoint, + control1: PhysicalFieldPoint, + control2: PhysicalFieldPoint, + end: PhysicalFieldPoint, + startParameter: number, + endParameter: number, + depth: number, + options: NormalizedPhysicalBezierOptions, + nodes: { parameter: number; point: PhysicalFieldPoint }[], +): void { + const chordLength = physicalDistance(start, end); + const controlPolygonLength = + physicalDistance(start, control1) + + physicalDistance(control1, control2) + + physicalDistance(control2, end); + const flatness = Math.max(0, controlPolygonLength - chordLength); + + if ( + depth >= options.maxSubdivisionDepth || + flatness <= options.toleranceMeters + ) { + nodes.push({ parameter: endParameter, point: clonePhysicalPoint(end) }); + return; + } + + const firstMidpoint = interpolatePhysicalPoint(start, control1, 0.5); + const secondMidpoint = interpolatePhysicalPoint(control1, control2, 0.5); + const thirdMidpoint = interpolatePhysicalPoint(control2, end, 0.5); + const leftMidpoint = interpolatePhysicalPoint( + firstMidpoint, + secondMidpoint, + 0.5, + ); + const rightMidpoint = interpolatePhysicalPoint( + secondMidpoint, + thirdMidpoint, + 0.5, + ); + const curveMidpoint = interpolatePhysicalPoint( + leftMidpoint, + rightMidpoint, + 0.5, + ); + const parameterMidpoint = (startParameter + endParameter) / 2; + + subdividePhysicalBezier( + start, + firstMidpoint, + leftMidpoint, + curveMidpoint, + startParameter, + parameterMidpoint, + depth + 1, + options, + nodes, + ); + subdividePhysicalBezier( + curveMidpoint, + rightMidpoint, + thirdMidpoint, + end, + parameterMidpoint, + endParameter, + depth + 1, + options, + nodes, + ); +} + +function locatePhysicalBezierArcLengthPoint( + geometry: PhysicalBezierTransitionGeometry, + lut: PhysicalBezierArcLengthLut, +): { readonly point: PhysicalFieldPoint; readonly parameter: number } { + if (lut.lengthMeters === 0) { + return { point: clonePhysicalPoint(geometry.start), parameter: 0 }; + } + + const targetDistance = lut.lengthMeters / 2; + let segmentIndex = lut.nodes.length - 2; + for (let index = 0; index < lut.nodes.length - 1; index += 1) { + if ( + targetDistance <= lut.nodes[index + 1].cumulativeLengthMeters || + index === lut.nodes.length - 2 + ) { + segmentIndex = index; + break; + } + } + + const segmentStart = lut.nodes[segmentIndex]; + const segmentEnd = lut.nodes[segmentIndex + 1]; + const segmentLength = + segmentEnd.cumulativeLengthMeters - segmentStart.cumulativeLengthMeters; + if (segmentLength === 0) { + return { + point: clonePhysicalPoint(segmentStart.point), + parameter: segmentStart.parameter, + }; + } + + const segmentRatio = + (targetDistance - segmentStart.cumulativeLengthMeters) / segmentLength; + const parameter = + segmentStart.parameter + + (segmentEnd.parameter - segmentStart.parameter) * segmentRatio; + return { + point: evaluatePhysicalCubic(geometry, parameter), + parameter, + }; +} + +function evaluatePhysicalCubic( + geometry: PhysicalBezierTransitionGeometry, + parameter: number, +): PhysicalFieldPoint { + const oneMinusT = 1 - parameter; + const oneMinusTSquared = oneMinusT * oneMinusT; + const tSquared = parameter * parameter; + const startWeight = oneMinusTSquared * oneMinusT; + const control1Weight = 3 * oneMinusTSquared * parameter; + const control2Weight = 3 * oneMinusT * tSquared; + const endWeight = tSquared * parameter; + return Object.freeze({ + xMeters: + geometry.start.xMeters * startWeight + + geometry.controlPoints[0].xMeters * control1Weight + + geometry.controlPoints[1].xMeters * control2Weight + + geometry.end.xMeters * endWeight, + yMeters: + geometry.start.yMeters * startWeight + + geometry.controlPoints[0].yMeters * control1Weight + + geometry.controlPoints[1].yMeters * control2Weight + + geometry.end.yMeters * endWeight, + }); +} + +function normalizePhysicalBezierOptions( + options: TransitionGeometryOptions, +): NormalizedPhysicalBezierOptions { + const toleranceSteps = + options.tolerance ?? DEFAULT_BEZIER_ARC_LENGTH_TOLERANCE_STEPS; + if (!Number.isFinite(toleranceSteps) || toleranceSteps <= 0) { + throw new RangeError( + "Bézier arc-length tolerance must be greater than zero.", + ); + } + const maxSubdivisionDepth = + options.maxSubdivisionDepth ?? DEFAULT_BEZIER_MAX_SUBDIVISION_DEPTH; + if ( + !Number.isInteger(maxSubdivisionDepth) || + maxSubdivisionDepth < 0 || + maxSubdivisionDepth > MAX_BEZIER_SUBDIVISION_DEPTH + ) { + throw new RangeError( + `Bézier subdivision depth must be an integer from 0 to ${MAX_BEZIER_SUBDIVISION_DEPTH}.`, + ); + } + return { + toleranceMeters: toleranceSteps * STANDARD_STEP_METERS, + maxSubdivisionDepth, + }; +} + +function clonePhysicalPoint(point: PhysicalFieldPoint): PhysicalFieldPoint { + return Object.freeze({ xMeters: point.xMeters, yMeters: point.yMeters }); +} + +function physicalDistance( + first: PhysicalFieldPoint, + second: PhysicalFieldPoint, +): number { + return Math.hypot( + second.xMeters - first.xMeters, + second.yMeters - first.yMeters, + ); +} + +function interpolatePhysicalPoint( + start: PhysicalFieldPoint, + end: PhysicalFieldPoint, + ratio: number, +): PhysicalFieldPoint { + return Object.freeze({ + xMeters: start.xMeters + (end.xMeters - start.xMeters) * ratio, + yMeters: start.yMeters + (end.yMeters - start.yMeters) * ratio, + }); +} diff --git a/packages/mobile/src/drill/render-scene.ts b/packages/mobile/src/drill/render-scene.ts new file mode 100644 index 00000000..083cd3c4 --- /dev/null +++ b/packages/mobile/src/drill/render-scene.ts @@ -0,0 +1,502 @@ +import { + convertPropSizeValue, + DEFAULT_PROP_SIZE, + drillGridToPhysicalPoint, + resolveDrillEntity, + resolveFieldDefinition, + type DrillDocument, + type DrillEntity, + type DrillGridPoint, + type EntityIcon, + type FieldDefinition, + type FieldPresetId, + type PhysicalFieldPoint, + type ResolvedDrillEntity, + type ResolvedFieldDefinition, +} from "@eight2five/drill-schema"; + +import { standardStepsToMeters } from "../field/units"; +import { + buildTransitionScene, + type ImmediateTransition, + type TransitionDot, + type TransitionSceneSettings, +} from "./transition-scene"; +import type { + TransitionGeometryOptions, + TransitionPathGeometry, +} from "./transition-geometry"; +import type { DrillSet } from "./types"; +import { withPhysicalTransitionMidpoint } from "./physical-transition-geometry"; + +/** + * Visibility settings used while deriving a selected-set render model. + * + * The model intentionally receives the small visibility contract instead of + * the complete AppSettings object. This keeps the domain helper independent of + * persistence and makes its memoization inputs explicit at the field boundary. + */ +export interface DrillRenderSceneSettings extends TransitionSceneSettings { + readonly showPerformerLabels: boolean; + readonly showPerformerNames: boolean; + readonly showPropLabels: boolean; + readonly showPropNames: boolean; +} + +export type DrillRenderFieldInput = + | FieldPresetId + | FieldDefinition + | ResolvedFieldDefinition; + +export interface DrillRenderSceneInput { + readonly document: DrillDocument; + /** The active field definition, not necessarily the document's default. */ + readonly field: DrillRenderFieldInput; + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + readonly settings: DrillRenderSceneSettings; + readonly geometryOptions?: TransitionGeometryOptions; + readonly epsilon?: number; +} + +export interface DrillRenderEntityBase { + readonly entityId: number; + readonly type: DrillEntity["type"]; + readonly symbol: string; + readonly label: string; + readonly name?: string; + readonly icon: EntityIcon; + readonly color: string; + readonly labelVisible: boolean; + readonly labelText?: string; + readonly nameText?: string; + readonly position: PhysicalFieldPoint; + readonly facingDegrees?: number; + /** Ordinary entities intentionally remain fully opaque. */ + readonly opacity: 1; + readonly resolvedEntity: ResolvedDrillEntity; +} + +export interface PerformerRenderEntity extends DrillRenderEntityBase { + readonly type: "performer"; + /** Exactly one standard 8-to-5 step in physical meters. */ + readonly diameterMeters: number; +} + +export interface PropRenderEntity extends DrillRenderEntityBase { + readonly type: "prop"; + readonly widthMeters: number; + readonly lengthMeters: number; +} + +export type DrillRenderEntity = PerformerRenderEntity | PropRenderEntity; + +export interface PhysicalStraightTransitionGeometry { + readonly kind: "straight"; + readonly start: PhysicalFieldPoint; + readonly end: PhysicalFieldPoint; +} + +export interface PhysicalPolylineTransitionGeometry { + readonly kind: "polyline"; + readonly points: readonly PhysicalFieldPoint[]; +} + +export interface PhysicalBezierTransitionGeometry { + readonly kind: "bezier"; + readonly start: PhysicalFieldPoint; + readonly controlPoints: readonly [PhysicalFieldPoint, PhysicalFieldPoint]; + readonly end: PhysicalFieldPoint; +} + +export type PhysicalTransitionPathGeometry = + | PhysicalStraightTransitionGeometry + | PhysicalPolylineTransitionGeometry + | PhysicalBezierTransitionGeometry; + +export interface PhysicalImmediateTransition { + readonly entityId: number; + readonly fromSetId: number; + readonly toSetId: number; + readonly start: PhysicalFieldPoint; + readonly end: PhysicalFieldPoint; + readonly geometry: PhysicalTransitionPathGeometry; + readonly lengthSteps: number; + readonly midpoint: PhysicalFieldPoint; + readonly midpointParameter?: number; +} + +export interface PhysicalTransitionDot { + readonly setId: number; + readonly point: PhysicalFieldPoint; +} + +export interface DrillRenderScene { + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + /** Null means that the selected performer has no position in this set. */ + readonly current: PhysicalFieldPoint | null; + /** Other performers and props in the selected source set. */ + readonly entities: readonly DrillRenderEntity[]; + readonly previous?: PhysicalImmediateTransition; + readonly next?: PhysicalImmediateTransition; + readonly previousDots: readonly PhysicalTransitionDot[]; + readonly nextDots: readonly PhysicalTransitionDot[]; +} + +/** + * The renderer's layer contract is kept as data so z-order cannot silently + * drift when a new overlay is added. + */ +export const DRILL_RENDER_LAYER_ORDER = Object.freeze([ + "static", + "anchors", + "entities", + "extra-dots", + "previous", + "next", + "current-target", + "guidance", + "live-position", +] as const); + +/** One standard 8-to-5 step, shared by performer and marker size math. */ +export const DEFAULT_PERFORMER_DIAMETER_METERS = standardStepsToMeters(1); + +/** + * Build all data needed by the Skia field layer for one selected set. + * + * This is deliberately a pure, immutable boundary. Entity resolution, + * position lookup, field projection, path projection, and transition math all + * happen here rather than inside the per-frame Skia tree. + */ +export function buildDrillRenderScene( + input: DrillRenderSceneInput, +): DrillRenderScene { + const field = resolveRenderField(input.field); + const resolvedEntities = input.document.entities.map((entity) => + freezeResolvedEntity( + resolveDrillEntity(entity, input.document.entityRules), + ), + ); + const positionsByEntityId = new Map(); + for (const position of input.document.positions) { + if (position.setId !== input.selectedSourceSetId) continue; + positionsByEntityId.set(position.entityId, position); + } + + const entities: DrillRenderEntity[] = []; + for (const resolved of resolvedEntities) { + // The selected performer is represented by the target/transition layers, + // never by the ordinary-entity layer. + if ( + resolved.type === "performer" && + resolved.id === input.selectedPerformerEntityId + ) { + continue; + } + const position = positionsByEntityId.get(resolved.id); + if (!position) continue; + entities.push( + createRenderEntity(resolved, position, field, input.settings), + ); + } + + const transitionScene = buildTransitionScene({ + document: input.document, + selectedPerformerEntityId: input.selectedPerformerEntityId, + selectedSourceSetId: input.selectedSourceSetId, + settings: input.settings, + geometryOptions: input.geometryOptions, + epsilon: input.epsilon, + }); + + return freezeScene({ + selectedPerformerEntityId: input.selectedPerformerEntityId, + selectedSourceSetId: input.selectedSourceSetId, + current: transitionScene.current + ? projectPoint(transitionScene.current, field) + : null, + entities, + ...(transitionScene.previous + ? { + previous: projectImmediateTransition( + transitionScene.previous, + field, + input.geometryOptions, + ), + } + : {}), + ...(transitionScene.next + ? { + next: projectImmediateTransition( + transitionScene.next, + field, + input.geometryOptions, + ), + } + : {}), + previousDots: transitionScene.previousDots.map((dot) => + projectTransitionDot(dot, field), + ), + nextDots: transitionScene.nextDots.map((dot) => + projectTransitionDot(dot, field), + ), + }); +} + +export const deriveDrillRenderScene = buildDrillRenderScene; +export const buildSelectedSetRenderScene = buildDrillRenderScene; + +/** The Drill settings master switch owns scene creation as well as visibility. */ +export function shouldBuildDrillRenderScene( + drillFeaturesEnabled: boolean, +): boolean { + return drillFeaturesEnabled; +} + +/** + * Imported local rows carry an opaque SQLite id as well as the portable set + * id. Rendering must always use the latter and never accidentally pass the + * local row id into the source document. + */ +export function resolveSelectedSourceSetId( + selectedSet?: Pick, +): number | undefined { + return selectedSet?.sourceSetId; +} + +/** Project a Phase 3 grid path without changing its endpoint semantics. */ +export function projectTransitionPathGeometry( + geometry: TransitionPathGeometry, + fieldInput: DrillRenderFieldInput, +): PhysicalTransitionPathGeometry { + const field = resolveRenderField(fieldInput); + switch (geometry.kind) { + case "straight": + return freezePhysicalGeometry({ + kind: "straight", + start: projectPoint(geometry.start, field), + end: projectPoint(geometry.end, field), + }); + case "polyline": + return freezePhysicalGeometry({ + kind: "polyline", + points: geometry.points.map((point) => projectPoint(point, field)), + }); + case "bezier": + return freezePhysicalGeometry({ + kind: "bezier", + start: projectPoint(geometry.start, field), + controlPoints: [ + projectPoint(geometry.controlPoints[0], field), + projectPoint(geometry.controlPoints[1], field), + ], + end: projectPoint(geometry.end, field), + }); + } +} + +export const projectDrillTransitionPath = projectTransitionPathGeometry; + +/** Resolve a prop's physical dimensions using the schema's unit conversion. */ +export function resolvePropPhysicalSize(entity: Pick): { + readonly widthMeters: number; + readonly lengthMeters: number; +} { + const size = entity.size ?? DEFAULT_PROP_SIZE; + return Object.freeze({ + widthMeters: convertPropSizeValue(size.width, size.unit, "meters"), + lengthMeters: convertPropSizeValue(size.length, size.unit, "meters"), + }); +} + +function createRenderEntity( + entity: ResolvedDrillEntity, + position: DrillPositionForSet, + field: ResolvedFieldDefinition, + settings: DrillRenderSceneSettings, +): DrillRenderEntity { + const labelText = + settingsForEntity(entity.type, settings).showLabels && + entity.appearance.labelVisible + ? entity.label + : undefined; + const nameText = settingsForEntity(entity.type, settings).showNames + ? entity.name + : undefined; + const base = { + entityId: entity.id, + type: entity.type, + symbol: entity.symbol, + label: entity.label, + ...(entity.name === undefined ? {} : { name: entity.name }), + icon: entity.appearance.icon, + color: entity.appearance.color, + labelVisible: entity.appearance.labelVisible, + ...(labelText === undefined ? {} : { labelText }), + ...(nameText === undefined ? {} : { nameText }), + position: projectPoint(position, field), + ...(position.facingDegrees === undefined + ? {} + : { facingDegrees: position.facingDegrees }), + opacity: 1 as const, + resolvedEntity: entity, + }; + + if (entity.type === "prop") { + const size = resolvePropPhysicalSize(entity); + return { + ...base, + type: "prop", + ...size, + }; + } + + return { + ...base, + type: "performer", + diameterMeters: DEFAULT_PERFORMER_DIAMETER_METERS, + }; +} + +function settingsForEntity( + type: DrillEntity["type"], + settings: DrillRenderSceneSettings, +): { readonly showLabels: boolean; readonly showNames: boolean } { + return type === "prop" + ? { + showLabels: settings.showPropLabels, + showNames: settings.showPropNames, + } + : { + showLabels: settings.showPerformerLabels, + showNames: settings.showPerformerNames, + }; +} + +function projectImmediateTransition( + transition: ImmediateTransition, + field: ResolvedFieldDefinition, + geometryOptions?: TransitionGeometryOptions, +): PhysicalImmediateTransition { + const projectedGeometry = projectTransitionPathGeometry( + transition.geometry, + field, + ); + return withPhysicalTransitionMidpoint( + { + entityId: transition.entityId, + fromSetId: transition.fromSetId, + toSetId: transition.toSetId, + start: projectPoint(transition.start, field), + end: projectPoint(transition.end, field), + geometry: projectedGeometry, + lengthSteps: transition.lengthSteps, + }, + projectedGeometry, + geometryOptions, + ); +} + +function projectTransitionDot( + dot: TransitionDot, + field: ResolvedFieldDefinition, +): PhysicalTransitionDot { + return Object.freeze({ + setId: dot.setId, + point: projectPoint(dot.point, field), + }); +} + +function projectPoint( + point: DrillGridPoint, + field: ResolvedFieldDefinition, +): PhysicalFieldPoint { + const projected = drillGridToPhysicalPoint(point, field); + return Object.freeze({ + xMeters: projected.xMeters, + yMeters: projected.yMeters, + }); +} + +function resolveRenderField( + fieldInput: DrillRenderFieldInput, +): ResolvedFieldDefinition { + if (typeof fieldInput === "string") { + return resolveFieldDefinition({ type: "preset", preset: fieldInput }); + } + if ("id" in fieldInput && "physicalGeometry" in fieldInput) { + return fieldInput; + } + return resolveFieldDefinition(fieldInput); +} + +function freezeResolvedEntity( + entity: ResolvedDrillEntity, +): ResolvedDrillEntity { + return Object.freeze({ + ...entity, + ...(entity.size === undefined + ? {} + : { size: Object.freeze({ ...entity.size }) }), + appearance: Object.freeze({ ...entity.appearance }), + }); +} + +function freezeScene(scene: { + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + readonly current: PhysicalFieldPoint | null; + readonly entities: readonly DrillRenderEntity[]; + readonly previous?: PhysicalImmediateTransition; + readonly next?: PhysicalImmediateTransition; + readonly previousDots: readonly PhysicalTransitionDot[]; + readonly nextDots: readonly PhysicalTransitionDot[]; +}): DrillRenderScene { + return Object.freeze({ + ...scene, + current: scene.current ? Object.freeze({ ...scene.current }) : null, + entities: Object.freeze( + scene.entities.map((entity) => Object.freeze(entity)), + ), + previousDots: Object.freeze([...scene.previousDots]), + nextDots: Object.freeze([...scene.nextDots]), + }); +} + +function freezePhysicalGeometry( + geometry: PhysicalTransitionPathGeometry, +): PhysicalTransitionPathGeometry { + switch (geometry.kind) { + case "straight": + return Object.freeze({ + kind: geometry.kind, + start: Object.freeze({ ...geometry.start }), + end: Object.freeze({ ...geometry.end }), + }); + case "polyline": + return Object.freeze({ + kind: geometry.kind, + points: Object.freeze( + geometry.points.map((point) => Object.freeze({ ...point })), + ), + }); + case "bezier": + return Object.freeze({ + kind: geometry.kind, + start: Object.freeze({ ...geometry.start }), + controlPoints: Object.freeze([ + Object.freeze({ ...geometry.controlPoints[0] }), + Object.freeze({ ...geometry.controlPoints[1] }), + ]) as readonly [PhysicalFieldPoint, PhysicalFieldPoint], + end: Object.freeze({ ...geometry.end }), + }); + } +} + +interface DrillPositionForSet extends DrillGridPoint { + readonly entityId: number; + readonly setId: number; + readonly facingDegrees?: number; +} diff --git a/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts b/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts new file mode 100644 index 00000000..571d4309 --- /dev/null +++ b/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts @@ -0,0 +1,72 @@ +import { + createDrillShapeGeometry, + getDrillLabelTransformPolicy, + getDrillShapeTransformPolicy, +} from "../render/drill-shape-policy"; + +describe("drill icon shape policy", () => { + test.each([ + "square", + "triangle", + "diamond", + "star", + "hexagon", + "cross", + ] as const)("covers the %s path primitive", (icon) => { + const shape = createDrillShapeGeometry(icon, 2, 2); + expect(shape.kind).toBe("path"); + if (shape.kind === "path") expect(shape.points.length).toBeGreaterThan(2); + }); + + test("triangle points upward in world coordinates despite camera scaleY(-1)", () => { + const shape = createDrillShapeGeometry("triangle", 2, 2); + expect(shape).toMatchObject({ kind: "path" }); + if (shape.kind !== "path") return; + + expect(shape.points[0]).toEqual({ x: 0, y: 1 }); + expect(shape.points[1].y).toBe(-1); + expect(shape.points[2].y).toBe(-1); + }); + + test("star's first point is the upright directional point rather than a mirrored bottom point", () => { + const shape = createDrillShapeGeometry("star", 2, 2); + expect(shape.kind).toBe("path"); + if (shape.kind !== "path") return; + + const highestWorldY = Math.max(...shape.points.map((point) => point.y)); + expect(shape.points[0].y).toBe(highestWorldY); + expect(shape.points[0].x).toBeCloseTo(0); + expect(shape.points[0].y).toBeCloseTo(1); + }); + + test("negates field facing rotation for the reflected camera while retaining radians", () => { + expect(getDrillShapeTransformPolicy()).toMatchObject({ + rotationRadians: 0, + origin: { x: 0, y: 0 }, + }); + expect(getDrillShapeTransformPolicy(90).rotationRadians).toBeCloseTo( + -Math.PI / 2, + ); + expect(getDrillShapeTransformPolicy(180).rotationRadians).toBeCloseTo( + -Math.PI, + ); + }); + + test("uses opposite Y scaling for screen-constant upright labels", () => { + expect(getDrillLabelTransformPolicy(0.25)).toEqual({ + scaleX: 0.25, + scaleY: -0.25, + }); + }); + + test("keeps dot and circle primitives circular", () => { + expect(createDrillShapeGeometry("dot", 2, 4)).toEqual({ + kind: "circle", + radius: 1, + }); + expect(createDrillShapeGeometry("circle", 2, 4)).toEqual({ + kind: "circle", + radius: 1, + }); + }); +}); diff --git a/packages/mobile/src/field/__tests__/field-overlay-policy.test.ts b/packages/mobile/src/field/__tests__/field-overlay-policy.test.ts index 58466763..de6b1187 100644 --- a/packages/mobile/src/field/__tests__/field-overlay-policy.test.ts +++ b/packages/mobile/src/field/__tests__/field-overlay-policy.test.ts @@ -1,5 +1,8 @@ import { + getCurrentTargetMarkerSource, + resolveCurrentTargetPosition, shouldShowFieldGuidance, + shouldShowFieldGuidanceForScene, shouldShowFieldTarget, type FieldDrillOverlayState, } from "../render/field-overlay-types"; @@ -34,4 +37,56 @@ describe("field drill overlay gating", () => { shouldShowFieldGuidance({ ...visible, guidanceEnabled: false }), ).toBe(false); }); + + test("does not fall back to a stale target when a complete scene has no current position", () => { + const target = { xMeters: 1, yMeters: 2 }; + const sceneWithoutCurrent = { + fullDrillSceneAvailable: true, + sceneHasCurrent: false, + legacyFallbackAvailable: true, + } as const; + + expect(getCurrentTargetMarkerSource(sceneWithoutCurrent)).toBe("none"); + expect( + resolveCurrentTargetPosition({ + fullDrillSceneAvailable: true, + sceneCurrent: null, + legacyFallback: target, + }), + ).toBeUndefined(); + expect(shouldShowFieldGuidanceForScene(visible, sceneWithoutCurrent)).toBe( + false, + ); + }); + + test("keeps legacy/manual target and guidance behavior without a full scene", () => { + const target = { xMeters: 1, yMeters: 2 }; + const legacy = { + fullDrillSceneAvailable: false, + sceneHasCurrent: false, + legacyFallbackAvailable: true, + } as const; + + expect(getCurrentTargetMarkerSource(legacy)).toBe("legacy-fallback"); + expect( + resolveCurrentTargetPosition({ + fullDrillSceneAvailable: false, + sceneCurrent: undefined, + legacyFallback: target, + }), + ).toEqual(target); + expect(shouldShowFieldGuidanceForScene(visible, legacy)).toBe(true); + }); + + test("uses the complete scene target when both scene and fallback positions exist", () => { + const sceneTarget = { xMeters: 3, yMeters: 4 }; + const fallbackTarget = { xMeters: 1, yMeters: 2 }; + expect( + resolveCurrentTargetPosition({ + fullDrillSceneAvailable: true, + sceneCurrent: sceneTarget, + legacyFallback: fallbackTarget, + }), + ).toBe(sceneTarget); + }); }); diff --git a/packages/mobile/src/field/render/drill-shape-policy.ts b/packages/mobile/src/field/render/drill-shape-policy.ts new file mode 100644 index 00000000..ae1974d6 --- /dev/null +++ b/packages/mobile/src/field/render/drill-shape-policy.ts @@ -0,0 +1,168 @@ +import type { EntityIcon } from "@eight2five/drill-schema"; + +/** Icons that can be rendered as a directional path or as a circle. */ +export type DrillShapeIcon = EntityIcon | "circle"; + +export interface DrillShapePoint { + readonly x: number; + readonly y: number; +} + +export interface DrillPathShape { + readonly kind: "path"; + readonly points: readonly DrillShapePoint[]; +} + +export interface DrillCircleShape { + readonly kind: "circle"; + readonly radius: number; +} + +export type DrillShapeGeometry = DrillPathShape | DrillCircleShape; + +export interface DrillShapeTransformPolicy { + /** Rotation to apply in the world-space Group under the camera reflection. */ + readonly rotationRadians: number; + /** Every shape is constructed around this local origin. */ + readonly origin: Readonly; +} + +export interface DrillLabelTransformPolicy { + readonly scaleX: number; + readonly scaleY: number; +} + +/** + * Label text is drawn in screen pixels inside the world-space camera group. + * The negative Y scale both cancels the camera reflection and keeps the text + * upright while the magnitude keeps its size constant across zoom levels. + */ +export function getDrillLabelTransformPolicy( + metersPerPixel: number, +): DrillLabelTransformPolicy { + "worklet"; + return { + scaleX: metersPerPixel, + scaleY: -metersPerPixel, + }; +} + +/** + * Construct a shape in world coordinates that appears upright after the field + * camera's scaleY(-1). A visually upward point therefore has a positive world + * Y coordinate, unlike an ordinary screen-space path. + */ +export function createDrillShapeGeometry( + icon: DrillShapeIcon, + width: number, + height: number, +): DrillShapeGeometry { + assertPositiveFinite(width, "Drill shape width"); + assertPositiveFinite(height, "Drill shape height"); + + if (icon === "dot" || icon === "circle") { + return Object.freeze({ + kind: "circle", + radius: Math.min(width, height) / 2, + }); + } + + const halfWidth = width / 2; + const halfHeight = height / 2; + const radius = Math.min(halfWidth, halfHeight); + let points: readonly DrillShapePoint[]; + + switch (icon) { + case "triangle": + points = [ + { x: 0, y: halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: -halfWidth, y: -halfHeight }, + ]; + break; + case "diamond": + points = [ + { x: 0, y: halfHeight }, + { x: halfWidth, y: 0 }, + { x: 0, y: -halfHeight }, + { x: -halfWidth, y: 0 }, + ]; + break; + case "star": + points = Array.from({ length: 10 }, (_, index) => { + const angle = Math.PI / 2 - (index * Math.PI) / 5; + const pointRadius = index % 2 === 0 ? radius : radius * 0.45; + return { + x: Math.cos(angle) * pointRadius * (halfWidth / radius), + y: Math.sin(angle) * pointRadius * (halfHeight / radius), + }; + }); + break; + case "hexagon": + points = Array.from({ length: 6 }, (_, index) => { + const angle = Math.PI / 2 - (index * Math.PI) / 3; + return { + x: Math.cos(angle) * halfWidth, + y: Math.sin(angle) * halfHeight, + }; + }); + break; + case "cross": { + const armWidth = halfWidth * 0.36; + const armHeight = halfHeight * 0.36; + points = [ + { x: -armWidth, y: halfHeight }, + { x: armWidth, y: halfHeight }, + { x: armWidth, y: armHeight }, + { x: halfWidth, y: armHeight }, + { x: halfWidth, y: -armHeight }, + { x: armWidth, y: -armHeight }, + { x: armWidth, y: -halfHeight }, + { x: -armWidth, y: -halfHeight }, + { x: -armWidth, y: -armHeight }, + { x: -halfWidth, y: -armHeight }, + { x: -halfWidth, y: armHeight }, + { x: -armWidth, y: armHeight }, + ]; + break; + } + case "square": + default: + points = [ + { x: -halfWidth, y: halfHeight }, + { x: halfWidth, y: halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: -halfWidth, y: -halfHeight }, + ]; + break; + } + + return Object.freeze({ + kind: "path", + points: Object.freeze(points.map((point) => Object.freeze(point))), + }); +} + +/** + * Convert a field-space facing angle to a Skia Group rotation beneath the + * camera's negative Y scale. Negating the angle preserves the same directional + * heading in the user's upright view while keeping rotation in radians. + */ +export function getDrillShapeTransformPolicy( + facingDegrees?: number, +): DrillShapeTransformPolicy { + if (facingDegrees !== undefined && !Number.isFinite(facingDegrees)) { + throw new RangeError("Drill facing degrees must be finite."); + } + return Object.freeze({ + rotationRadians: + facingDegrees === undefined ? 0 : (-facingDegrees * Math.PI) / 180, + origin: Object.freeze({ x: 0, y: 0 }), + }); +} + +function assertPositiveFinite(value: number, name: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a positive finite number.`); + } +} diff --git a/packages/mobile/src/field/render/field-canvas.tsx b/packages/mobile/src/field/render/field-canvas.tsx index 11f1080e..6a609fe0 100644 --- a/packages/mobile/src/field/render/field-canvas.tsx +++ b/packages/mobile/src/field/render/field-canvas.tsx @@ -11,6 +11,7 @@ import { useSharedValue, type SharedValue } from "react-native-reanimated"; import { setFieldCamera } from "../camera/field-camera-math"; import type { FieldPresetId } from "@eight2five/drill-schema"; +import type { DrillRenderScene } from "../../drill/render-scene"; import { createStandardFootballFieldTemplate, @@ -49,6 +50,7 @@ export interface FieldCanvasProps { readonly palette?: FieldRenderPalette; readonly livePosition?: SharedValue; readonly targetPosition?: FieldPoint; + readonly drillScene?: DrillRenderScene; readonly guidanceVisible?: boolean; readonly anchors?: readonly FieldAnchorGeometry[]; readonly anchorOverlayOptions?: FieldAnchorOverlayOptions; @@ -69,6 +71,7 @@ export function FieldCanvas({ palette = DEFAULT_FIELD_RENDER_PALETTE, livePosition: externalLivePosition, targetPosition, + drillScene, guidanceVisible = false, anchors = EMPTY_FIELD_ANCHORS, anchorOverlayOptions = HIDDEN_FIELD_ANCHOR_OVERLAY, @@ -166,6 +169,7 @@ export function FieldCanvas({ palette={palette} livePosition={livePosition} targetPosition={targetPosition} + drillScene={drillScene} guidanceVisible={guidanceVisible} anchors={anchors} anchorOverlayOptions={anchorOverlayOptions} diff --git a/packages/mobile/src/field/render/field-drill-layer.tsx b/packages/mobile/src/field/render/field-drill-layer.tsx new file mode 100644 index 00000000..d48b1b79 --- /dev/null +++ b/packages/mobile/src/field/render/field-drill-layer.tsx @@ -0,0 +1,456 @@ +import React from "react"; +import { Montserrat_400Regular } from "@expo-google-fonts/montserrat/400Regular"; +import { + Circle, + DashPathEffect, + Group, + Path, + Skia, + Text, + useFont, + type SkFont, + type SkPath, +} from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; +import type { PhysicalFieldPoint } from "@eight2five/drill-schema"; + +import type { + DrillRenderEntity, + DrillRenderScene, + PhysicalImmediateTransition, + PhysicalTransitionPathGeometry, +} from "../../drill/render-scene"; +import type { FieldPoint } from "../types"; +import { resolveCurrentTargetPosition } from "./field-overlay-types"; +import { + createDrillShapeGeometry, + getDrillLabelTransformPolicy, + getDrillShapeTransformPolicy, + type DrillShapeIcon, +} from "./drill-shape-policy"; +import type { FieldRenderPalette } from "./field-render-tokens"; +import { + DRILL_MARKER_COLORS, + DRILL_MARKER_SIZE_METERS, +} from "./field-render-tokens"; + +const EMPTY_ENTITIES: readonly DrillRenderEntity[] = Object.freeze([]); +const EMPTY_DOTS = Object.freeze([]) as readonly { + readonly setId: number; + readonly point: PhysicalFieldPoint; +}[]; +const LABEL_FONT_SIZE_PX = 12; +const LABEL_LINE_HEIGHT_PX = 14; +const MARKER_STROKE_PX = 2; +const CONNECTOR_STROKE_PX = 1.25; +const DASH_LENGTH_PX = 6; +const DASH_GAP_PX = 4; + +export interface FieldDrillLayerProps { + readonly scene?: DrillRenderScene; + /** Used only for legacy/manual drills that have no complete source document. */ + readonly fallbackTargetPosition?: FieldPoint; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; +} + +/** + * Draws the selected-set model in explicit z-order. Static field and anchors + * are owned by the parent scene; guidance and the live position are drawn + * after this layer so they remain visible above every drill entity. + */ +export const FieldDrillLayer = React.memo(function FieldDrillLayer({ + scene, + fallbackTargetPosition, + metersPerPixel, + palette, +}: FieldDrillLayerProps) { + const labelFont = useFont(Montserrat_400Regular, LABEL_FONT_SIZE_PX); + const entities = scene?.entities ?? EMPTY_ENTITIES; + const previousDots = scene?.previousDots ?? EMPTY_DOTS; + const nextDots = scene?.nextDots ?? EMPTY_DOTS; + const targetPoint = resolveCurrentTargetPosition({ + fullDrillSceneAvailable: scene !== undefined, + sceneCurrent: scene?.current, + legacyFallback: fallbackTargetPosition, + }); + + return ( + <> + {entities.map((entity) => ( + + ))} + {previousDots.map((dot) => ( + + ))} + {nextDots.map((dot) => ( + + ))} + {scene?.previous ? ( + + ) : null} + {scene?.next ? ( + + ) : null} + {targetPoint ? ( + + ) : null} + + ); +}); + +function OrdinaryEntity({ + entity, + labelFont, + metersPerPixel, + palette, +}: { + readonly entity: DrillRenderEntity; + readonly labelFont: SkFont | null; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; +}) { + const icon = entity.icon as string; + const width = + entity.type === "prop" ? entity.widthMeters : entity.diameterMeters; + const height = + entity.type === "prop" ? entity.lengthMeters : entity.diameterMeters; + const shapeGeometry = React.useMemo( + () => createDrillShapeGeometry(icon as DrillShapeIcon, width, height), + [height, icon, width], + ); + const shapePath = React.useMemo( + () => + shapeGeometry.kind === "path" + ? createShapePath(shapeGeometry.points) + : null, + [shapeGeometry], + ); + const transformPolicy = React.useMemo( + () => getDrillShapeTransformPolicy(entity.facingDegrees), + [entity.facingDegrees], + ); + const transform = React.useMemo( + () => [ + { translateX: entity.position.xMeters }, + { translateY: entity.position.yMeters }, + ...(transformPolicy.rotationRadians === 0 + ? [] + : [{ rotate: transformPolicy.rotationRadians }]), + ], + [ + entity.position.xMeters, + entity.position.yMeters, + transformPolicy.rotationRadians, + ], + ); + const outlineWidth = useDerivedValue(() => metersPerPixel.value); + + return ( + <> + + {shapePath ? ( + <> + + {entity.type === "prop" ? ( + + ) : null} + + ) : ( + + )} + + + + ); +} + +function EntityLabel({ + entity, + font, + metersPerPixel, + color, +}: { + readonly entity: DrillRenderEntity; + readonly font: SkFont | null; + readonly metersPerPixel: SharedValue; + readonly color: string; +}) { + const lines = React.useMemo( + () => + [ + entity.labelText ? { key: "label", text: entity.labelText } : null, + entity.nameText ? { key: "name", text: entity.nameText } : null, + ].filter((line): line is { key: string; text: string } => line !== null), + [entity.labelText, entity.nameText], + ); + const labelTransform = useDerivedValue(() => { + const labelScale = getDrillLabelTransformPolicy(metersPerPixel.value); + return [ + { translateX: entity.position.xMeters }, + { translateY: entity.position.yMeters }, + { scaleX: labelScale.scaleX }, + // The camera has a negative Y scale. This restores upright screen text + // and makes the label size independent of zoom. + { scaleY: labelScale.scaleY }, + ]; + }); + + if (!font || lines.length === 0) return null; + const widths = lines.map((line) => font.measureText(line.text).width); + const startY = -LABEL_LINE_HEIGHT_PX * (lines.length + 0.15); + + return ( + + {lines.map((line, index) => ( + + ))} + + ); +} + +function ExtraDot({ + point, + color, +}: { + readonly point: PhysicalFieldPoint; + readonly color: string; +}) { + return ( + + ); +} + +function ImmediateTransitionLayer({ + transition, + kind, + metersPerPixel, +}: { + readonly transition: PhysicalImmediateTransition; + readonly kind: "previous" | "next"; + readonly metersPerPixel: SharedValue; +}) { + const connectorPath = React.useMemo( + () => createPhysicalPath(transition.geometry), + [transition.geometry], + ); + const markerDiameter = DRILL_MARKER_SIZE_METERS.transitionDiameter; + const markerPath = React.useMemo( + () => createCirclePath(markerDiameter / 2), + [markerDiameter], + ); + const markerPoint = kind === "previous" ? transition.start : transition.end; + const markerTransform = React.useMemo( + () => [ + { translateX: markerPoint.xMeters }, + { translateY: markerPoint.yMeters }, + ], + [markerPoint.xMeters, markerPoint.yMeters], + ); + const markerStrokeWidth = useDerivedValue( + () => metersPerPixel.value * MARKER_STROKE_PX, + ); + const connectorStrokeWidth = useDerivedValue( + () => metersPerPixel.value * CONNECTOR_STROKE_PX, + ); + const dashIntervals = useDerivedValue(() => [ + metersPerPixel.value * DASH_LENGTH_PX, + metersPerPixel.value * DASH_GAP_PX, + ]); + const connectorColor = + kind === "previous" ? DRILL_MARKER_COLORS.red : DRILL_MARKER_COLORS.green; + const centerRadius = markerDiameter * 0.18; + + return ( + <> + + + {kind === "previous" ? ( + + + + ) : ( + <> + + + + )} + + + + + ); +} + +function CurrentTargetMarker({ + point, + metersPerPixel, +}: { + readonly point: PhysicalFieldPoint | FieldPoint; + readonly metersPerPixel: SharedValue; +}) { + const diameter = DRILL_MARKER_SIZE_METERS.currentDiameter; + const ringPath = React.useMemo( + () => createCirclePath(diameter / 2), + [diameter], + ); + const transform = React.useMemo( + () => [{ translateX: point.xMeters }, { translateY: point.yMeters }], + [point.xMeters, point.yMeters], + ); + const strokeWidth = useDerivedValue( + () => metersPerPixel.value * MARKER_STROKE_PX, + ); + + return ( + + {/* The ring is intentionally not filled; its interior stays transparent. */} + + + + ); +} + +function createPhysicalPath(geometry: PhysicalTransitionPathGeometry): SkPath { + const builder = Skia.PathBuilder.Make(); + switch (geometry.kind) { + case "straight": + builder + .moveTo(geometry.start.xMeters, geometry.start.yMeters) + .lineTo(geometry.end.xMeters, geometry.end.yMeters); + break; + case "polyline": { + const first = geometry.points[0]; + if (!first) return builder.build(); + builder.moveTo(first.xMeters, first.yMeters); + for (const point of geometry.points.slice(1)) { + builder.lineTo(point.xMeters, point.yMeters); + } + break; + } + case "bezier": + builder + .moveTo(geometry.start.xMeters, geometry.start.yMeters) + .cubicTo( + geometry.controlPoints[0].xMeters, + geometry.controlPoints[0].yMeters, + geometry.controlPoints[1].xMeters, + geometry.controlPoints[1].yMeters, + geometry.end.xMeters, + geometry.end.yMeters, + ); + break; + } + return builder.build(); +} + +function createCirclePath(radius: number): SkPath { + return Skia.PathBuilder.Make().addCircle(0, 0, radius).build(); +} + +function createShapePath( + points: readonly { readonly x: number; readonly y: number }[], +): SkPath { + const builder = Skia.PathBuilder.Make(); + const first = points[0]; + if (!first) return builder.build(); + builder.moveTo(first.x, first.y); + for (const point of points.slice(1)) builder.lineTo(point.x, point.y); + return builder.close().build(); +} diff --git a/packages/mobile/src/field/render/field-overlay-types.ts b/packages/mobile/src/field/render/field-overlay-types.ts index 46f482d0..9a571705 100644 --- a/packages/mobile/src/field/render/field-overlay-types.ts +++ b/packages/mobile/src/field/render/field-overlay-types.ts @@ -1,4 +1,4 @@ -import type { FieldPosition } from "../types"; +import type { FieldPoint, FieldPosition } from "../types"; export interface FieldAnchorGeometry { readonly id: string; @@ -26,6 +26,62 @@ export interface FieldDrillOverlayState { readonly guidanceEnabled: boolean; } +export type CurrentTargetMarkerSource = + | "drill-scene" + | "legacy-fallback" + | "none"; + +export interface CurrentTargetMarkerPolicyInput { + readonly fullDrillSceneAvailable: boolean; + readonly sceneHasCurrent: boolean; + readonly legacyFallbackAvailable: boolean; +} + +/** + * A complete scene owns the selected target, even when its current position is + * missing. This prevents a stale local fallback from drawing a duplicate or + * misleading target on top of a document-backed scene. + */ +export function getCurrentTargetMarkerSource({ + fullDrillSceneAvailable, + sceneHasCurrent, + legacyFallbackAvailable, +}: CurrentTargetMarkerPolicyInput): CurrentTargetMarkerSource { + if (fullDrillSceneAvailable) { + return sceneHasCurrent ? "drill-scene" : "none"; + } + return legacyFallbackAvailable ? "legacy-fallback" : "none"; +} + +export function resolveCurrentTargetPosition({ + fullDrillSceneAvailable, + sceneCurrent, + legacyFallback, +}: { + readonly fullDrillSceneAvailable: boolean; + readonly sceneCurrent?: FieldPoint | null; + readonly legacyFallback?: FieldPoint; +}): FieldPoint | undefined { + const source = getCurrentTargetMarkerSource({ + fullDrillSceneAvailable, + sceneHasCurrent: sceneCurrent !== undefined && sceneCurrent !== null, + legacyFallbackAvailable: legacyFallback !== undefined, + }); + if (source === "drill-scene") return sceneCurrent ?? undefined; + if (source === "legacy-fallback") return legacyFallback; + return undefined; +} + +export function shouldShowFieldGuidanceForScene( + state: FieldDrillOverlayState, + targetPolicy: CurrentTargetMarkerPolicyInput, +): boolean { + return ( + shouldShowFieldGuidance(state) && + getCurrentTargetMarkerSource(targetPolicy) !== "none" + ); +} + export function shouldShowFieldTarget(state: FieldDrillOverlayState): boolean { return ( state.drillFeaturesEnabled && state.hasActiveDrill && state.hasSelectedPage diff --git a/packages/mobile/src/field/render/field-position-layer.tsx b/packages/mobile/src/field/render/field-position-layer.tsx index f334b73e..cd1f0a6b 100644 --- a/packages/mobile/src/field/render/field-position-layer.tsx +++ b/packages/mobile/src/field/render/field-position-layer.tsx @@ -1,4 +1,4 @@ -import { Circle, Group, Path } from "@shopify/react-native-skia"; +import { Circle, Group } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import type { FieldPoint } from "../types"; @@ -6,12 +6,10 @@ import type { FieldRenderPalette } from "./field-render-tokens"; export function FieldPositionLayer({ livePosition, - targetPosition, metersPerPixel, palette, }: { readonly livePosition: SharedValue; - readonly targetPosition?: FieldPoint; readonly metersPerPixel: SharedValue; readonly palette: FieldRenderPalette; }) { @@ -28,29 +26,8 @@ export function FieldPositionLayer({ const liveOpacity = useDerivedValue(() => livePosition.value === null ? 0 : 1, ); - const targetTransform = useDerivedValue(() => { - const scale = metersPerPixel.value; - return [ - { translateX: targetPosition?.xMeters ?? -1_000_000 }, - { translateY: targetPosition?.yMeters ?? -1_000_000 }, - { scaleX: scale }, - { scaleY: -scale }, - ]; - }, [targetPosition?.xMeters, targetPosition?.yMeters]); - return ( <> - {targetPosition ? ( - - - - - ) : null} diff --git a/packages/mobile/src/field/render/field-render-tokens.ts b/packages/mobile/src/field/render/field-render-tokens.ts index f9875488..02a44cb8 100644 --- a/packages/mobile/src/field/render/field-render-tokens.ts +++ b/packages/mobile/src/field/render/field-render-tokens.ts @@ -1,5 +1,7 @@ import { COLOR_PRESETS } from "@eight2five/drill-schema"; +import { STANDARD_STEP_METERS } from "../units"; + export const FIELD_FOUR_STEP_GRID_COLOR = "#6FA0E1"; function colorWithOpacity(color: `#${string}`, opacity: number): string { @@ -17,12 +19,35 @@ export interface FieldRenderPalette { readonly fieldLines: string; readonly fieldNumbers: string; readonly livePosition: string; - readonly target: string; readonly guidance: string; readonly anchor: string; readonly anchorRange: string; } +/** Schema-synchronized colors used by the selected performer's markers. */ +export const DRILL_MARKER_COLORS = Object.freeze({ + yellow: COLOR_PRESETS.yellow, + red: COLOR_PRESETS.red, + green: COLOR_PRESETS.green, +}); + +/** Marker diameters are physical sizes expressed in standard steps. */ +export const DRILL_MARKER_SIZE_STEPS = Object.freeze({ + currentDiameter: 1.5, + transitionDiameter: 0.75, + midpointDiameter: 0.375, +}); + +/** Physical marker sizes keep their world meaning while the camera zooms. */ +export const DRILL_MARKER_SIZE_METERS = Object.freeze({ + currentDiameter: + DRILL_MARKER_SIZE_STEPS.currentDiameter * STANDARD_STEP_METERS, + transitionDiameter: + DRILL_MARKER_SIZE_STEPS.transitionDiameter * STANDARD_STEP_METERS, + midpointDiameter: + DRILL_MARKER_SIZE_STEPS.midpointDiameter * STANDARD_STEP_METERS, +}); + export const DEFAULT_FIELD_RENDER_PALETTE: FieldRenderPalette = Object.freeze({ canvasBackground: "#E7EAF0", stepGrid: "rgba(76, 93, 120, 0.22)", @@ -31,7 +56,6 @@ export const DEFAULT_FIELD_RENDER_PALETTE: FieldRenderPalette = Object.freeze({ fieldLines: "#5D6470", fieldNumbers: "#69717D", livePosition: COLOR_PRESETS.blue, - target: "#D29B22", guidance: colorWithOpacity(COLOR_PRESETS.blue, 0.74), anchor: "#7B5CC7", anchorRange: "rgba(123, 92, 199, 0.14)", diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx index 54a99f17..9e9686e7 100644 --- a/packages/mobile/src/field/render/field-scene.tsx +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -18,6 +18,8 @@ import type { FieldAnchorOverlayOptions, } from "./field-overlay-types"; import type { FieldRenderPalette } from "./field-render-tokens"; +import type { DrillRenderScene } from "../../drill/render-scene"; +import { FieldDrillLayer } from "./field-drill-layer"; interface FieldSceneProps { readonly camera: FieldCamera; @@ -27,6 +29,7 @@ interface FieldSceneProps { readonly palette: FieldRenderPalette; readonly livePosition: SharedValue; readonly targetPosition?: FieldPoint; + readonly drillScene?: DrillRenderScene; readonly guidanceVisible: boolean; readonly anchors: readonly FieldAnchorGeometry[]; readonly anchorOverlayOptions: FieldAnchorOverlayOptions; @@ -42,6 +45,7 @@ export function FieldScene({ palette, livePosition, targetPosition, + drillScene, guidanceVisible, anchors, anchorOverlayOptions, @@ -73,6 +77,12 @@ export function FieldScene({ metersPerPixel={camera.metersPerPixel} palette={palette} /> + {guidanceVisible && targetPosition ? ( diff --git a/packages/mobile/src/field/render/index.ts b/packages/mobile/src/field/render/index.ts index 88968502..9becda4a 100644 --- a/packages/mobile/src/field/render/index.ts +++ b/packages/mobile/src/field/render/index.ts @@ -4,3 +4,5 @@ export * from "./field-canvas"; export * from "./page-dial-canvas"; export * from "./field-overlay-types"; export * from "./yard-number-layout"; +export * from "./field-drill-layer"; +export * from "./drill-shape-policy"; diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 605aa63b..ecae1bd9 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -40,6 +40,7 @@ export * from "./field/camera/field-camera-policy"; export * from "./field/render/create-field-paths"; export * from "./field/render/field-render-tokens"; export * from "./field/render/field-overlay-types"; +export * from "./field/render/drill-shape-policy"; export * from "./drill"; export * from "./settings"; export * from "./storage"; From c5ff191fd3b78440fd8998cfa8eb84e9787d9660 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 17:04:55 -0500 Subject: [PATCH 070/101] feat(mobile): add PANS commissioning and IMU fusion Simplify performer tag setup while preserving serialized configuration and readback behavior. Add developer network and anchor commissioning plus bounded phone-motion interpolation for short UWB gaps. --- apps/mobile/app.config.ts | 7 + apps/mobile/app/(tabs)/settings/_layout.tsx | 6 + .../(tabs)/settings/network/[networkId].tsx | 9 + .../settings/network/[networkId]/anchors.tsx | 9 + apps/mobile/app/(tabs)/settings/networks.tsx | 5 + apps/mobile/app/_layout.tsx | 18 +- .../settings/__tests__/developer-mode.test.ts | 2 + .../settings/__tests__/network-form.test.ts | 95 +++ .../settings/__tests__/network-ui.test.ts | 99 +++ .../tag-connection-lifecycle.test.ts | 24 + .../settings/anchor-editor-screen.tsx | 8 +- .../settings/connection-status-row.tsx | 99 +++ .../settings/developer-settings-screen.tsx | 50 ++ .../settings/network-detail-screen.tsx | 515 +++++++++++++ .../src/features/settings/network-form.ts | 86 +++ .../settings/network-profile-form.tsx | 105 +++ .../src/features/settings/network-ui.ts | 98 +++ .../src/features/settings/networks-screen.tsx | 265 +++++++ .../features/settings/settings-components.tsx | 17 +- .../src/features/settings/settings-screen.tsx | 20 +- .../settings/tag-connection-lifecycle.ts | 20 + .../settings/tag-connection-screen.tsx | 361 ++++++---- .../settings/use-anchor-editor-controller.ts | 6 + .../mobile-pans-position-publisher.test.ts | 168 +++++ .../pans/__tests__/mobile-pans-store.test.ts | 214 +++++- .../src/pans/__tests__/mobile-pans-ui.test.ts | 69 ++ .../pans/mobile-pans-connection-controller.ts | 9 + apps/mobile/src/pans/mobile-pans-context.tsx | 45 +- apps/mobile/src/pans/mobile-pans-model.ts | 23 +- .../pans/mobile-pans-position-publisher.ts | 331 +++++++-- apps/mobile/src/pans/mobile-pans-runtime.ts | 11 +- apps/mobile/src/pans/mobile-pans-store.ts | 681 ++++++++++++++++-- apps/mobile/src/pans/mobile-pans-ui.ts | 95 +++ .../__tests__/manager-provider.test.tsx | 1 + .../manager-settings-screen.test.tsx | 1 + .../src/pans-manager/manager-context.tsx | 1 + .../screens/manager-settings-screen.tsx | 1 + packages/mobile/package.json | 1 + packages/mobile/src/field/live-position.ts | 34 +- packages/mobile/src/index.ts | 1 + .../motion/__tests__/device-motion.test.ts | 79 ++ .../motion/__tests__/position-fusion.test.ts | 191 +++++ packages/mobile/src/motion/device-motion.ts | 145 ++++ packages/mobile/src/motion/index.ts | 2 + packages/mobile/src/motion/position-fusion.ts | 358 +++++++++ .../pans-manager/PansConfigurationService.ts | 9 +- .../__tests__/performer-tag-profile.test.ts | 66 ++ packages/mobile/src/pans-manager/index.ts | 1 + .../src/pans-manager/performer-tag-profile.ts | 37 + packages/mobile/src/pans-manager/types.ts | 24 + 50 files changed, 4241 insertions(+), 281 deletions(-) create mode 100644 apps/mobile/app/(tabs)/settings/network/[networkId].tsx create mode 100644 apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx create mode 100644 apps/mobile/app/(tabs)/settings/networks.tsx create mode 100644 apps/mobile/src/features/settings/__tests__/network-form.test.ts create mode 100644 apps/mobile/src/features/settings/__tests__/network-ui.test.ts create mode 100644 apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts create mode 100644 apps/mobile/src/features/settings/connection-status-row.tsx create mode 100644 apps/mobile/src/features/settings/network-detail-screen.tsx create mode 100644 apps/mobile/src/features/settings/network-form.ts create mode 100644 apps/mobile/src/features/settings/network-profile-form.tsx create mode 100644 apps/mobile/src/features/settings/network-ui.ts create mode 100644 apps/mobile/src/features/settings/networks-screen.tsx create mode 100644 apps/mobile/src/features/settings/tag-connection-lifecycle.ts create mode 100644 apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts create mode 100644 apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts create mode 100644 apps/mobile/src/pans/mobile-pans-ui.ts create mode 100644 packages/mobile/src/motion/__tests__/device-motion.test.ts create mode 100644 packages/mobile/src/motion/__tests__/position-fusion.test.ts create mode 100644 packages/mobile/src/motion/device-motion.ts create mode 100644 packages/mobile/src/motion/index.ts create mode 100644 packages/mobile/src/motion/position-fusion.ts create mode 100644 packages/mobile/src/pans-manager/__tests__/performer-tag-profile.test.ts create mode 100644 packages/mobile/src/pans-manager/performer-tag-profile.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index fbc8e8f4..bc60c800 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -95,6 +95,13 @@ const config: ExpoConfig = { }, }, ], + [ + "expo-sensors", + { + motionPermission: + "Allow $(PRODUCT_NAME) to use device motion for brief live-position prediction.", + }, + ], [ "../../modules/expo-pans-ble-api/app.plugin.js", { diff --git a/apps/mobile/app/(tabs)/settings/_layout.tsx b/apps/mobile/app/(tabs)/settings/_layout.tsx index 03efa659..01c09f1c 100644 --- a/apps/mobile/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile/app/(tabs)/settings/_layout.tsx @@ -27,6 +27,12 @@ export default function SettingsLayout() { name="developer-confirmation" options={{ title: "Enable Developer Mode" }} /> + + + diff --git a/apps/mobile/app/(tabs)/settings/network/[networkId].tsx b/apps/mobile/app/(tabs)/settings/network/[networkId].tsx new file mode 100644 index 00000000..e6dbc9db --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/network/[networkId].tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; + +import { NetworkDetailScreen } from "../../../../src/features/settings/network-detail-screen"; + +export default function NetworkDetailRoute() { + const { networkId } = useLocalSearchParams<{ networkId: string }>(); + + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx b/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx new file mode 100644 index 00000000..3a0a26ab --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; + +import { NetworkDetailScreen } from "../../../../../src/features/settings/network-detail-screen"; + +export default function NetworkAnchorsRoute() { + const { networkId } = useLocalSearchParams<{ networkId: string }>(); + + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/networks.tsx b/apps/mobile/app/(tabs)/settings/networks.tsx new file mode 100644 index 00000000..2a48874c --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/networks.tsx @@ -0,0 +1,5 @@ +import { NetworksScreen } from "../../../src/features/settings/networks-screen"; + +export default function NetworksRoute() { + return ; +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index cfa6712c..28650fdd 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -41,9 +41,9 @@ export default function MobileRootLayout() { - + - + @@ -51,6 +51,20 @@ export default function MobileRootLayout() { ); } +function MobilePansWithSettings({ children }: { children: React.ReactNode }) { + const { status, settings } = useAppSettingsSnapshot(); + return ( + + {children} + + ); +} + function MobileAppearance({ children }: { children: React.ReactNode }) { const { settings } = useAppSettingsSnapshot(); return ( diff --git a/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts index 1de8457d..c1d7b0ba 100644 --- a/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts +++ b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts @@ -45,6 +45,8 @@ describe("Developer Mode", () => { effectiveUpdateRateHz: 9.5, diagnosticMessages: [], knownAnchors: [], + networks: [], + discoveryRssiCutoff: -75, }); expect(rows).toEqual( diff --git a/apps/mobile/src/features/settings/__tests__/network-form.test.ts b/apps/mobile/src/features/settings/__tests__/network-form.test.ts new file mode 100644 index 00000000..437ccc8e --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/network-form.test.ts @@ -0,0 +1,95 @@ +import { + DEFAULT_MANAGED_NETWORK_SETTINGS, + type ManagedNetwork, +} from "@eight2five/mobile/pans-manager"; + +import { networkDraftFromNetwork, validateNetworkDraft } from "../network-form"; + +jest.mock("expo-pans-ble-api", () => ({})); +jest.mock("react-native-worklets", () => ({ + ...jest.requireActual("react-native-worklets/lib/module/mock"), + scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) => + callback(...args), +})); +jest.mock("react-native-reanimated", () => + jest.requireActual("react-native-reanimated/mock"), +); +jest.mock( + "@shopify/react-native-skia", + () => ({ + Canvas: () => null, + Fill: () => null, + Group: () => null, + Path: () => null, + Circle: () => null, + Line: () => null, + Rect: () => null, + useFont: () => ({}), + vec: (x: number, y: number) => ({ x, y }), + }), + { virtual: true }, +); + +describe("mobile network profile validation", () => { + test("accepts trimmed names and hexadecimal PAN IDs", () => { + const result = validateNetworkDraft({ + name: " Stadium ", + panId: "0x00a0", + }); + + expect(result).toEqual({ + errors: {}, + value: { name: "Stadium", panId: 160 }, + }); + }); + + test("rejects an empty name, reserved PAN 0, and duplicate PAN IDs", () => { + const networks = [network("existing", "Existing", 160)]; + + expect(validateNetworkDraft({ name: " ", panId: "0" }, networks)).toEqual({ + errors: { + name: "Enter a network name.", + panId: + "Saved network PAN ID must be an integer from 1 to 65535; PAN 0 is the PANS default used for unassigned devices.", + }, + }); + expect( + validateNetworkDraft({ name: "Other", panId: "160" }, networks).errors, + ).toEqual({ panId: "A network with this PAN ID already exists." }); + }); + + test("rejects duplicate names without blocking the network being edited", () => { + const existing = network("existing", "Field", 160); + const other = network("other", "Auxiliary", 161); + + expect( + validateNetworkDraft( + { name: " field ", panId: "0x00a2" }, + [existing, other], + existing.id, + ), + ).toEqual({ errors: {}, value: { name: "field", panId: 162 } }); + expect( + validateNetworkDraft({ name: "FIELD", panId: "162" }, [existing, other]) + .errors, + ).toEqual({ name: "A network with this name already exists." }); + }); + + test("round-trips the detail form draft", () => { + expect(networkDraftFromNetwork(network("one", "One", 42))).toEqual({ + name: "One", + panId: "42", + }); + }); +}); + +function network(id: string, name: string, panId: number): ManagedNetwork { + return { + id, + name, + panId, + settings: DEFAULT_MANAGED_NETWORK_SETTINGS, + createdAt: 1, + updatedAt: 1, + }; +} diff --git a/apps/mobile/src/features/settings/__tests__/network-ui.test.ts b/apps/mobile/src/features/settings/__tests__/network-ui.test.ts new file mode 100644 index 00000000..59ea85b3 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/network-ui.test.ts @@ -0,0 +1,99 @@ +import type { + DiscoveredDeviceSnapshot, + ManagedDevice, +} from "@eight2five/mobile/pans-manager"; + +import { + anchorInitiatorLabel, + commissioningWarningText, + selectAssociatedCachedAnchors, + selectNetworkAnchorDiscoveries, +} from "../network-ui"; + +describe("mobile network commissioning presentation", () => { + test("keeps associated cached anchors and presents initiator state", () => { + const associated = anchor("one", "network-a", true); + const unrelated = anchor("two", "network-b", false); + + expect( + selectAssociatedCachedAnchors("network-a", [associated, unrelated]), + ).toEqual([associated]); + expect(anchorInitiatorLabel(associated)).toBe("Yes"); + expect(anchorInitiatorLabel(anchor("unknown", undefined, false))).toBe( + "No", + ); + expect( + anchorInitiatorLabel({ ...associated, lastKnownConfig: undefined }), + ).toBe("Unknown"); + }); + + test("starts with current compatible discoveries and keeps uncached anchors", () => { + const cached = anchor("cached", "network-a", false); + const rows = selectNetworkAnchorDiscoveries( + [ + discovery("uncached", "anchor", -50), + discovery("cached", "anchor", -60), + discovery("tag", "tag", -55), + { ...discovery("stale", "anchor", -40), stale: true }, + { ...discovery("weak", "anchor", -90) }, + { ...discovery("bad", "anchor", -45), compatibility: "incompatible" }, + ], + [cached], + -75, + ); + + expect(rows.map((row) => row.discovery.transportDeviceId)).toEqual([ + "uncached", + "tag", + "cached", + ]); + expect(rows[0].cachedAnchor).toBeUndefined(); + expect(rows[1].requiresRoleChangeConfirmation).toBe(true); + expect(rows[2].cachedAnchor).toBe(cached); + expect(rows[2].requiresRoleChangeConfirmation).toBe(false); + }); + + test("does not invent a success message when verification is incomplete", () => { + const warning = "Initiator set, but one prior anchor was unreachable."; + expect(commissioningWarningText(warning)).toBe(warning); + expect(commissioningWarningText(" ")).toBeUndefined(); + expect(commissioningWarningText(undefined)).toBeUndefined(); + }); +}); + +function discovery( + transportDeviceId: string, + role: "tag" | "anchor", + rssi: number, +): DiscoveredDeviceSnapshot { + return { + transportDeviceId, + name: transportDeviceId, + rssi, + lastSeenAt: 1, + compatibility: "compatible", + presence: { role } as never, + }; +} + +function anchor( + id: string, + networkId: string | undefined, + initiatorEnabled: boolean, +): ManagedDevice { + return { + id, + networkId, + transportDeviceId: id, + role: "anchor", + lastKnownConfig: { + role: "anchor", + uwbMode: "active", + initiatorEnabled, + ledEnabled: true, + firmwareUpdateEnabled: true, + }, + createdAt: 1, + updatedAt: 1, + }; +} diff --git a/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts b/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts new file mode 100644 index 00000000..a8f05560 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts @@ -0,0 +1,24 @@ +import { ownTagDiscoveryWhileFocused } from "../tag-connection-lifecycle"; + +describe("Tag Connection discovery lifecycle", () => { + test("focus starts a page-owned scan and blur stops it", async () => { + const store = { + startTagDiscovery: jest.fn(async () => undefined), + stopManualDiscovery: jest.fn(), + }; + const cleanup = ownTagDiscoveryWhileFocused(store, true, false, jest.fn()); + await Promise.resolve(); + expect(store.startTagDiscovery).toHaveBeenCalledTimes(1); + cleanup(); + expect(store.stopManualDiscovery).toHaveBeenCalledTimes(1); + }); + + test("does not replace an already-connected global session", () => { + const store = { + startTagDiscovery: jest.fn(async () => undefined), + stopManualDiscovery: jest.fn(), + }; + ownTagDiscoveryWhileFocused(store, true, true, jest.fn()); + expect(store.startTagDiscovery).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx index bd647ef5..91762918 100644 --- a/apps/mobile/src/features/settings/anchor-editor-screen.tsx +++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx @@ -184,12 +184,12 @@ export function AnchorEditorScreen({ ) : null} - {controller.connectionState !== "connected" ? ( + {!controller.canWritePosition ? ( - A live tag connection is required before the confirmed hardware - write. + Select this anchor's network as active or connect an associated + tag before the confirmed hardware write. ) : null} @@ -197,7 +197,7 @@ export function AnchorEditorScreen({ testID="save-anchor-position-button" isDisabled={ !controller.validation.position || - controller.connectionState !== "connected" || + !controller.canWritePosition || controller.saving } onPress={() => { diff --git a/apps/mobile/src/features/settings/connection-status-row.tsx b/apps/mobile/src/features/settings/connection-status-row.tsx new file mode 100644 index 00000000..e808d84d --- /dev/null +++ b/apps/mobile/src/features/settings/connection-status-row.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { Animated, Easing } from "react-native"; +import { + Bluetooth, + BluetoothConnected, + BluetoothOff, + LoaderCircle, + TriangleAlert, +} from "lucide-react-native"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Text } from "@eight2five/ui/components/text"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import type { TagConnectionState } from "../../pans/mobile-pans-model"; +import { connectionStatusViewModel } from "../../pans/mobile-pans-ui"; + +const ICONS = { + connected: BluetoothConnected, + searching: LoaderCircle, + connecting: Bluetooth, + disconnected: BluetoothOff, + error: TriangleAlert, +} as const; + +export function ConnectionStatusRow({ + state, +}: { + readonly state: TagConnectionState; +}) { + const theme = useEight2FiveTheme(); + const presentation = connectionStatusViewModel(state); + const [spin] = React.useState(() => new Animated.Value(0)); + + React.useEffect(() => { + if (!presentation.animated) { + spin.stopAnimation(); + spin.setValue(0); + return; + } + const animation = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + animation.start(); + return () => animation.stop(); + }, [presentation.animated, spin]); + + const color = + presentation.tone === "success" + ? theme.success + : presentation.tone === "accent" + ? theme.accent + : presentation.tone === "danger" + ? theme.danger + : theme.textMuted; + const icon = ( + + ); + return ( + + {presentation.animated ? ( + + {icon} + + ) : ( + icon + )} + + {presentation.label} + + + ); +} diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx index 26d51551..dc3a540d 100644 --- a/apps/mobile/src/features/settings/developer-settings-screen.tsx +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -7,8 +7,10 @@ import { Database, Grid3X3, MapPinned, + Network, RefreshCw, Radio, + SlidersHorizontal, Triangle, } from "lucide-react-native"; import { @@ -59,6 +61,9 @@ export function DeveloperSettingsScreen() { const [rangeDraft, setRangeDraft] = React.useState(() => settings.comfortableAnchorRangeMeters.toString(), ); + const [rssiDraft, setRssiDraft] = React.useState(() => + pans.discoveryRssiCutoff.toString(), + ); const rows = React.useMemo(() => buildDeveloperDiagnosticRows(pans), [pans]); const disable = async () => { @@ -104,6 +109,9 @@ export function DeveloperSettingsScreen() { }; const rangeValidation = parseComfortableAnchorRange(rangeDraft); + const parsedRssi = Number(rssiDraft); + const validRssi = + Number.isInteger(parsedRssi) && parsedRssi >= -100 && parsedRssi <= -30; if (!settings.developerModeEnabled) { return ( @@ -136,6 +144,11 @@ export function DeveloperSettingsScreen() { {(operationError ?? settingsError)?.message} ) : null} + {pans.commissioningWarning ? ( + + {pans.commissioningWarning} + + ) : null} + + + + + + + + router.push("/(tabs)/settings/networks" as never)} + testID="networks-link" + /> item.id === networkId); + const [draftOverride, setDraftOverride] = React.useState(); + const draft = React.useMemo( + () => + draftOverride ?? + (network ? networkDraftFromNetwork(network) : { name: "", panId: "" }), + [draftOverride, network], + ); + const connectionStateRef = React.useRef(snapshot.connectionState); + React.useEffect(() => { + connectionStateRef.current = snapshot.connectionState; + }, [snapshot.connectionState]); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + + useFocusEffect( + React.useCallback(() => { + if ( + settings.developerModeEnabled && + snapshot.initialization === "ready" && + connectionStateRef.current !== "connected" + ) { + void store + .startTagDiscovery() + .catch((cause) => setError(toError(cause))); + } + return () => store.stopManualDiscovery(); + }, [settings.developerModeEnabled, snapshot.initialization, store]), + ); + + const validation = React.useMemo( + () => validateNetworkDraft(draft, snapshot.networks, network?.id), + [draft, network?.id, snapshot.networks], + ); + const associatedAnchors = React.useMemo( + () => selectAssociatedCachedAnchors(networkId, snapshot.knownAnchors), + [networkId, snapshot.knownAnchors], + ); + const discoveryRows = React.useMemo( + () => + selectNetworkAnchorDiscoveries( + snapshot.discoveries, + snapshot.knownAnchors, + snapshot.discoveryRssiCutoff, + ), + [snapshot.discoveries, snapshot.discoveryRssiCutoff, snapshot.knownAnchors], + ); + + if (snapshot.initialization === "loading") { + return ( + + + Preparing network details… + + + ); + } + + if (!settings.developerModeEnabled) { + return ( + + + Enable Developer Mode before managing PANS networks. + + + ); + } + + if (!network) { + return ( + + + The selected network no longer exists. + + + ); + } + + const run = async (action: () => Promise) => { + if (busy) return; + setBusy(true); + setError(undefined); + try { + await action(); + } catch (cause) { + setError(toError(cause)); + } finally { + setBusy(false); + } + }; + + const ensureActiveNetwork = async () => { + if (snapshot.activeNetworkId !== network.id) { + await store.setActiveNetwork(network.id); + } + }; + + const save = () => { + if (!validation.value) return; + void run(async () => { + const saved = await store.updateNetwork(network.id, { + name: validation.value!.name, + panId: validation.value!.panId, + }); + setDraftOverride(networkDraftFromNetwork(saved)); + }); + }; + + const setActive = () => + void run(async () => { + await store.setActiveNetwork(network.id); + }); + + const openAnchor = (anchorId: string) => { + router.push({ + pathname: "/(tabs)/settings/anchor/[anchorId]", + params: { anchorId }, + }); + }; + + const persistDiscovery = async ( + row: NetworkAnchorDiscoveryRow, + confirmRoleChange: boolean, + ) => { + await run(async () => { + await ensureActiveNetwork(); + if (row.cachedAnchor && row.advertisedRole === "anchor") { + await store.assignDeviceToActiveNetwork(row.cachedAnchor.id); + openAnchor(row.cachedAnchor.id); + return; + } + const saved = await store.persistDiscoveredAnchor( + row.discovery.transportDeviceId, + confirmRoleChange, + ); + if (saved.networkId !== network.id) { + await store.assignDeviceToActiveNetwork(saved.id); + } + openAnchor(saved.id); + }); + }; + + const handleDiscovery = (row: NetworkAnchorDiscoveryRow) => { + if ( + row.cachedAnchor?.networkId === network.id && + row.advertisedRole === "anchor" + ) { + openAnchor(row.cachedAnchor.id); + return; + } + if (row.requiresRoleChangeConfirmation) { + Alert.alert( + "Convert device to anchor?", + `${ + row.discovery.name ?? row.discovery.transportDeviceId + } is advertising as ${ + row.advertisedRole === "unknown" ? "an unknown role" : "a tag" + }. Converting it changes the PANS hardware role and may interrupt its current use.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Convert to Anchor", + style: "destructive", + onPress: () => void persistDiscovery(row, true), + }, + ], + ); + return; + } + void persistDiscovery(row, false); + }; + + const confirmDelete = () => { + Alert.alert( + "Delete network profile?", + `Delete ${network.name}? Its cached device associations will be removed, but this does not erase hardware.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete Network", + style: "destructive", + onPress: () => + void run(async () => { + await store.deleteNetwork(network.id); + router.back(); + }), + }, + ], + ); + }; + + return ( + + {error || snapshot.error ? ( + + {(error ?? snapshot.error)?.message} + + ) : null} + {commissioningWarningText(snapshot.commissioningWarning) ? ( + + {commissioningWarningText(snapshot.commissioningWarning)} + + ) : null} + + + + + + + + + + + + + + + + {associatedAnchors.length === 0 ? ( + + + + No cached anchors are associated with this network yet. Start with + the live discovery list below. + + + ) : ( + associatedAnchors.map((anchor) => ( + openAnchor(anchor.id)} + onSetInitiator={() => + void run(async () => { + await ensureActiveNetwork(); + await store.setAnchorInitiator(anchor.id); + }) + } + /> + )) + )} + + + + {snapshot.connectionState === "connected" ? ( + + Disconnect the current performer tag before starting anchor + discovery. + + ) : null} + {discoveryRows.length === 0 ? ( + + {snapshot.connectionState === "scanning" + ? "Looking for compatible PANS devices…" + : "No current compatible devices meet the signal requirement."} + + ) : ( + discoveryRows.map((row) => ( + handleDiscovery(row)} + /> + )) + )} + + + + + {snapshot.commissioningWarning ? ( + + + + Hardware verification is incomplete. Keep the warning above until + the unreachable anchors can be checked. + + + ) : null} + + ); +} + +function CachedAnchorRow({ + anchor, + busy, + onEdit, + onSetInitiator, +}: { + readonly anchor: ManagedDevice; + readonly busy: boolean; + readonly onEdit: () => void; + readonly onSetInitiator: () => void; +}) { + const theme = useEight2FiveTheme(); + const initiator = anchorInitiatorLabel(anchor); + return ( + + + + + + + {anchor.nodeIdHex ?? anchor.label ?? anchor.id} + + + Initiator: {initiator} + + + {anchor.transportDeviceId} + + + + + + {initiator === "Yes" ? ( + + ) : ( + + )} + + ); +} + +function DiscoveryAnchorRow({ + row, + busy, + onPress, +}: { + readonly row: NetworkAnchorDiscoveryRow; + readonly busy: boolean; + readonly onPress: () => void; +}) { + const theme = useEight2FiveTheme(); + const cached = row.cachedAnchor; + const title = + cached?.nodeIdHex ?? + cached?.label ?? + row.discovery.name ?? + row.discovery.transportDeviceId; + const action = cached ? "Assign to this network" : "Add as anchor"; + return ( + + + + + + {title} + + + {row.advertisedRole === "unknown" + ? "Unknown role" + : `Advertised ${row.advertisedRole}`}{" "} + · {row.discovery.rssi} dBm + + + {cached + ? `Cached · Initiator: ${anchorInitiatorLabel(cached)}` + : "Not cached"} + + + + + + ); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/features/settings/network-form.ts b/apps/mobile/src/features/settings/network-form.ts new file mode 100644 index 00000000..bf5e7889 --- /dev/null +++ b/apps/mobile/src/features/settings/network-form.ts @@ -0,0 +1,86 @@ +import { + assertNetworkProfilePanId, + assertUniqueName, + parsePanId, + type ManagedNetwork, +} from "@eight2five/mobile/pans-manager"; + +export interface NetworkDraft { + readonly name: string; + readonly panId: string; +} + +export interface NetworkDraftErrors { + readonly name?: string; + readonly panId?: string; +} + +export interface ValidatedNetworkDraft { + readonly name: string; + readonly panId: number; +} + +export interface NetworkDraftValidation { + readonly errors: NetworkDraftErrors; + readonly value?: ValidatedNetworkDraft; +} + +/** + * Validates the small app-local network profile form before it reaches the + * store. The store remains the authority for persistence and validates again; + * this keeps incomplete forms from starting an asynchronous operation. + */ +export function validateNetworkDraft( + draft: NetworkDraft, + networks: readonly ManagedNetwork[] = [], + currentNetworkId?: string, +): NetworkDraftValidation { + const errors: { name?: string; panId?: string } = {}; + const name = draft.name.trim(); + + if (!name) { + errors.name = "Enter a network name."; + } else { + const currentName = networks.find( + (network) => network.id === currentNetworkId, + )?.name; + try { + assertUniqueName( + name, + networks.map((network) => network.name), + currentName, + ); + } catch (cause) { + errors.name = toMessage(cause, "That network name is already in use."); + } + } + + let panId: number | undefined; + try { + panId = parsePanId(draft.panId); + assertNetworkProfilePanId(panId); + } catch (cause) { + errors.panId = toMessage(cause, "Enter a PAN ID from 1 to 65535."); + } + + if ( + panId !== undefined && + networks.some( + (network) => network.id !== currentNetworkId && network.panId === panId, + ) + ) { + errors.panId = "A network with this PAN ID already exists."; + } + + return Object.keys(errors).length > 0 + ? { errors } + : { errors, value: { name, panId: panId! } }; +} + +export function networkDraftFromNetwork(network: ManagedNetwork): NetworkDraft { + return { name: network.name, panId: String(network.panId) }; +} + +function toMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message ? cause.message : fallback; +} diff --git a/apps/mobile/src/features/settings/network-profile-form.tsx b/apps/mobile/src/features/settings/network-profile-form.tsx new file mode 100644 index 00000000..6df12e72 --- /dev/null +++ b/apps/mobile/src/features/settings/network-profile-form.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { + FormControl, + FormControlError, + FormControlErrorText, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { + Button, + ButtonIcon, + ButtonSpinner, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Save } from "lucide-react-native"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing } from "@eight2five/ui/theme"; + +import type { NetworkDraft, NetworkDraftErrors } from "./network-form"; + +export function NetworkProfileForm({ + draft, + errors, + saving, + submitLabel, + onChange, + onSubmit, +}: { + readonly draft: NetworkDraft; + readonly errors: NetworkDraftErrors; + readonly saving: boolean; + readonly submitLabel: string; + readonly onChange: (draft: NetworkDraft) => void; + readonly onSubmit: () => void; +}) { + return ( + + + + Network name + + + onChange({ ...draft, name })} + /> + + + + A local name for this PANS network profile. + + + {errors.name ? ( + + {errors.name} + + ) : null} + + + + + PAN ID + + + onChange({ ...draft, panId })} + /> + + + + Enter decimal or hexadecimal (for example, 0x1234). PAN 0 is + reserved for unassigned devices. + + + {errors.panId ? ( + + {errors.panId} + + ) : null} + + + + + ); +} diff --git a/apps/mobile/src/features/settings/network-ui.ts b/apps/mobile/src/features/settings/network-ui.ts new file mode 100644 index 00000000..f1841d01 --- /dev/null +++ b/apps/mobile/src/features/settings/network-ui.ts @@ -0,0 +1,98 @@ +import type { + DiscoveredDeviceSnapshot, + ManagedDevice, +} from "@eight2five/mobile/pans-manager"; + +export type AdvertisedAnchorRole = "anchor" | "tag" | "unknown"; + +export interface NetworkAnchorDiscoveryRow { + readonly discovery: DiscoveredDeviceSnapshot; + readonly cachedAnchor?: ManagedDevice; + readonly advertisedRole: AdvertisedAnchorRole; + readonly requiresRoleChangeConfirmation: boolean; +} + +export function isManagedAnchor(device: ManagedDevice): boolean { + return device.role === "anchor" || device.lastKnownConfig?.role === "anchor"; +} + +export function selectAssociatedCachedAnchors( + networkId: string, + knownAnchors: readonly ManagedDevice[], +): readonly ManagedDevice[] { + return knownAnchors.filter( + (anchor) => anchor.networkId === networkId && isManagedAnchor(anchor), + ); +} + +/** + * Discovery is intentionally the first source for this list. A cached device + * is attached to a discovery row when the transport identity matches, but a + * compatible advertisement with no cache entry is retained as well. + */ +export function selectNetworkAnchorDiscoveries( + discoveries: readonly DiscoveredDeviceSnapshot[], + knownAnchors: readonly ManagedDevice[], + cutoff: number, +): readonly NetworkAnchorDiscoveryRow[] { + return discoveries + .filter( + (discovery) => + !discovery.stale && + discovery.compatibility === "compatible" && + discovery.rssi >= cutoff, + ) + .sort((left, right) => { + const rssiDifference = right.rssi - left.rssi; + return rssiDifference !== 0 + ? rssiDifference + : left.transportDeviceId.localeCompare(right.transportDeviceId); + }) + .map((discovery) => { + const cachedAnchor = knownAnchors.find( + (anchor) => + normalizeTransportKey(anchor.transportDeviceId) === + normalizeTransportKey(discovery.transportDeviceId) && + isManagedAnchor(anchor), + ); + const advertisedRole = advertisedRoleForDiscovery(discovery); + return { + discovery, + ...(cachedAnchor ? { cachedAnchor } : {}), + advertisedRole, + requiresRoleChangeConfirmation: advertisedRole !== "anchor", + }; + }); +} + +export function advertisedRoleForDiscovery( + discovery: DiscoveredDeviceSnapshot, +): AdvertisedAnchorRole { + const role = discovery.presence?.role; + return role === "anchor" || role === "tag" ? role : "unknown"; +} + +export function anchorInitiatorLabel( + anchor: ManagedDevice | undefined, +): "Yes" | "No" | "Unknown" { + const config = + anchor?.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig + : undefined; + return config ? (config.initiatorEnabled ? "Yes" : "No") : "Unknown"; +} + +/** Preserve the store's wording; an absent warning must not imply verification. */ +export function commissioningWarningText( + warning: string | undefined, +): string | undefined { + return warning?.trim() || undefined; +} + +function normalizeTransportKey(deviceId: string): string { + const trimmed = deviceId.trim(); + const compact = trimmed.replace(/[:-]/g, ""); + return /^[0-9a-f]+$/i.test(compact) + ? compact.toUpperCase() + : trimmed.toLocaleLowerCase(); +} diff --git a/apps/mobile/src/features/settings/networks-screen.tsx b/apps/mobile/src/features/settings/networks-screen.tsx new file mode 100644 index 00000000..dd065626 --- /dev/null +++ b/apps/mobile/src/features/settings/networks-screen.tsx @@ -0,0 +1,265 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { + Check, + ChevronRight, + Network, + Plus, + TriangleAlert, +} from "lucide-react-native"; +import { + formatPanId, + type ManagedNetwork, +} from "@eight2five/mobile/pans-manager"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { NetworkProfileForm } from "./network-profile-form"; +import { validateNetworkDraft, type NetworkDraft } from "./network-form"; +import { commissioningWarningText } from "./network-ui"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +const EMPTY_DRAFT: NetworkDraft = { name: "", panId: "" }; + +export function NetworksScreen() { + const router = useRouter(); + const theme = useEight2FiveTheme(); + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const { settings } = useAppSettingsSnapshot(); + const [creating, setCreating] = React.useState(false); + const [draft, setDraft] = React.useState(EMPTY_DRAFT); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + const validation = React.useMemo( + () => validateNetworkDraft(draft, snapshot.networks), + [draft, snapshot.networks], + ); + const activeNetwork = snapshot.networks.find( + (network) => network.id === snapshot.activeNetworkId, + ); + + if (!snapshot.initialization || snapshot.initialization === "loading") { + return ( + + Preparing PANS networks… + + ); + } + + if (!settings.developerModeEnabled) { + return ( + + + Enable Developer Mode before managing PANS networks. + + + ); + } + + const run = async (action: () => Promise) => { + if (busy) return; + setBusy(true); + setError(undefined); + try { + await action(); + } catch (cause) { + setError(toError(cause)); + } finally { + setBusy(false); + } + }; + + const save = () => { + if (!validation.value) return; + void run(async () => { + const network = await store.createNetwork( + validation.value!.name, + validation.value!.panId, + ); + setDraft(EMPTY_DRAFT); + setCreating(false); + openNetwork(router, network); + }); + }; + + return ( + + {error || snapshot.error ? ( + + {(error ?? snapshot.error)?.message} + + ) : null} + {commissioningWarningText(snapshot.commissioningWarning) ? ( + + {commissioningWarningText(snapshot.commissioningWarning)} + + ) : null} + + + + + + + + + + {creating ? ( + + + + ) : null} + + {snapshot.networks.length === 0 ? ( + + + + No network profiles are saved on this device. + + + ) : ( + snapshot.networks.map((network) => ( + openNetwork(router, network)} + onSetActive={() => + void run(async () => { + await store.setActiveNetwork(network.id); + }) + } + /> + )) + )} + + + + ); +} + +function NetworkRow({ + network, + active, + disabled, + onOpen, + onSetActive, +}: { + readonly network: ManagedNetwork; + readonly active: boolean; + readonly disabled: boolean; + readonly onOpen: () => void; + readonly onSetActive: () => void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + + + + {network.name} + + + PAN {formatPanId(network.panId)} + + + {active ? ( + + + + Active + + + ) : null} + + + + {!active ? ( + + ) : null} + + ); +} + +function openNetwork( + router: ReturnType, + network: ManagedNetwork, +) { + router.push({ + pathname: "/(tabs)/settings/network/[networkId]" as never, + params: { networkId: network.id }, + }); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/features/settings/settings-components.tsx b/apps/mobile/src/features/settings/settings-components.tsx index c9a58ab9..21bd0eb3 100644 --- a/apps/mobile/src/features/settings/settings-components.tsx +++ b/apps/mobile/src/features/settings/settings-components.tsx @@ -270,21 +270,32 @@ export function SettingsMessage({ tone, children, }: { - tone: "info" | "error"; + tone: "info" | "error" | "warning"; children: React.ReactNode; }) { const theme = useEight2FiveTheme(); + const color = + tone === "error" + ? theme.danger + : tone === "warning" + ? theme.warning + : theme.text; return ( - + {children} diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 46ea5ce3..03f18892 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useRouter } from "expo-router"; import { + Activity, BookOpenText, Code2, Eye, @@ -32,6 +33,7 @@ import { useAppSettingsStore, } from "../../state/app-settings-store"; import { ResetSettingsControl } from "./reset-settings-control"; +import { ConnectionStatusRow } from "./connection-status-row"; import { updateDrillFeatures } from "./settings-actions"; import { shouldShowTransitionCountControls } from "./settings-screen-policy"; import { @@ -41,7 +43,6 @@ import { SettingsSection, SettingsSelectRow, SettingsSwitchRow, - SettingsValueRow, } from "./settings-components"; const PERSPECTIVE_CHOICES = [ @@ -137,11 +138,7 @@ export function SettingsScreen() { onPress={() => router.push("/(tabs)/settings/tag")} testID="tag-connection-link" /> - + @@ -198,6 +195,17 @@ export function SettingsScreen() { disabled={disabled} testID="auxiliary-field-marks-setting" /> + + void update({ motionInterpolationEnabled }) + } + disabled={disabled} + testID="motion-interpolation-setting" + /> diff --git a/apps/mobile/src/features/settings/tag-connection-lifecycle.ts b/apps/mobile/src/features/settings/tag-connection-lifecycle.ts new file mode 100644 index 00000000..03e08673 --- /dev/null +++ b/apps/mobile/src/features/settings/tag-connection-lifecycle.ts @@ -0,0 +1,20 @@ +export interface TagDiscoveryPageOwner { + startTagDiscovery(): Promise; + stopManualDiscovery(): void; +} + +export function ownTagDiscoveryWhileFocused( + store: TagDiscoveryPageOwner, + servicesReady: boolean, + alreadyConnected: boolean, + onError: (error: Error) => void, +): () => void { + if (servicesReady && !alreadyConnected) { + void store + .startTagDiscovery() + .catch((cause) => + onError(cause instanceof Error ? cause : new Error(String(cause))), + ); + } + return () => store.stopManualDiscovery(); +} diff --git a/apps/mobile/src/features/settings/tag-connection-screen.tsx b/apps/mobile/src/features/settings/tag-connection-screen.tsx index 50bc430d..50d0e68d 100644 --- a/apps/mobile/src/features/settings/tag-connection-screen.tsx +++ b/apps/mobile/src/features/settings/tag-connection-screen.tsx @@ -1,202 +1,186 @@ import React from "react"; -import { useFocusEffect } from "expo-router"; +import { useFocusEffect, useRouter } from "expo-router"; import { - Bluetooth, BluetoothConnected, - BluetoothOff, - RefreshCw, + Edit3, + Network, + Signal, + SignalHigh, + SignalLow, + SignalMedium, Trash2, TriangleAlert, } from "lucide-react-native"; import { Button, ButtonIcon, - ButtonSpinner, ButtonText, } from "@eight2five/ui/components/button"; import { HStack } from "@eight2five/ui/components/hstack"; import { Icon } from "@eight2five/ui/components/icon"; +import { Input, InputField } from "@eight2five/ui/components/input"; import { Pressable } from "@eight2five/ui/components/pressable"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; import { useMobilePansSnapshot, useMobilePansStore, } from "../../pans/mobile-pans-context"; -import { isSelectableTagDiscovery } from "../../pans/mobile-pans-model"; +import { + selectVisibleDiscoveries, + signalStrengthForRssi, + type SignalStrength, +} from "../../pans/mobile-pans-ui"; +import { ConnectionStatusRow } from "./connection-status-row"; +import { ownTagDiscoveryWhileFocused } from "./tag-connection-lifecycle"; import { SettingsMessage, + SettingsNavigationRow, SettingsScreenContainer, SettingsSection, + SettingsSelectRow, SettingsValueRow, } from "./settings-components"; -const BUSY_STATES = new Set(["scanning", "connecting", "reconnecting"]); +const SIGNAL_ICONS: Record = { + full: Signal, + high: SignalHigh, + medium: SignalMedium, + low: SignalLow, +}; export function TagConnectionScreen() { + return ; +} + +/** Shared route/modal body. Discovery ownership follows focus lifecycle. */ +export function TagConnectionContent() { + const router = useRouter(); const theme = useEight2FiveTheme(); const store = useMobilePansStore(); const snapshot = useMobilePansSnapshot(); - const [operation, setOperation] = React.useState(); + const { settings } = useAppSettingsSnapshot(); + const developerMode = settings.developerModeEnabled; + const [operation, setOperation] = React.useState(false); const [error, setError] = React.useState(); - const busy = BUSY_STATES.has(snapshot.connectionState) || Boolean(operation); - const candidates = snapshot.discoveries.filter(isSelectableTagDiscovery); + const [labelEdit, setLabelEdit] = React.useState<{ + readonly deviceId?: string; + readonly value: string; + }>({ value: "" }); + const candidates = React.useMemo( + () => + selectVisibleDiscoveries(snapshot.discoveries, { + developerMode, + cutoff: snapshot.discoveryRssiCutoff, + }), + [developerMode, snapshot.discoveries, snapshot.discoveryRssiCutoff], + ); + + const selectedLabel = + snapshot.rememberedTag?.lastKnownConfig?.label ?? + snapshot.rememberedTag?.label ?? + ""; + const labelDraft = + labelEdit.deviceId === snapshot.rememberedTag?.id + ? labelEdit.value + : selectedLabel; useFocusEffect( - React.useCallback(() => () => store.stopManualDiscovery(), [store]), + React.useCallback(() => { + return ownTagDiscoveryWhileFocused( + store, + snapshot.initialization === "ready", + false, + setError, + ); + }, [snapshot.initialization, store]), ); - const run = async (name: string, action: () => Promise) => { + const run = async (action: () => Promise) => { if (operation) return; - setOperation(name); + setOperation(true); setError(undefined); try { await action(); } catch (cause) { - setError(cause instanceof Error ? cause : new Error(String(cause))); + setError(toError(cause)); } finally { - setOperation(undefined); + setOperation(false); } }; return ( - {snapshot.initialization === "loading" ? ( - Preparing PANS services… - ) : null} {snapshot.error || error ? ( {(error ?? snapshot.error)?.message} ) : null} - - - {snapshot.rememberedTag?.nodeIdHex ? ( - - ) : null} - - - - - {busy ? ( - - ) : null} - + + + {snapshot.rememberedTag.lastKnownConfig?.label ?? + snapshot.rememberedTag.label ?? + "Selected tag"} + - - - - + ) : null} - {snapshot.connectionState === "scanning" || candidates.length > 0 ? ( - - {candidates.length === 0 ? ( - - - - Scanning for compatible tags… - - - ) : ( - candidates.map((device) => ( + + {candidates.length === 0 ? ( + + {snapshot.connectionState === "scanning" + ? "Looking for nearby tags…" + : "No nearby tags meet the signal requirement."} + + ) : ( + candidates.map((device) => { + const strength = signalStrengthForRssi( + device.rssi, + snapshot.discoveryRssiCutoff, + ); + const role = device.presence?.role; + return ( - void run("select", async () => { - await store.selectTag(device.transportDeviceId); - await store.stopDiscovery(); + void run(async () => { + if (role === "tag") { + await store.selectConfigureAndConnectTag( + device.transportDeviceId, + ); + } else if (developerMode) { + await store.persistDiscoveredAnchor( + device.transportDeviceId, + ); + } }) } > @@ -204,32 +188,103 @@ export function TagConnectionScreen() { className="items-center" style={{ gap: 12, padding: eight2FiveSpacing.md }} > - - - - {device.name ?? "PANS Tag"} - + {developerMode ? ( - {device.transportDeviceId} + {device.rssi} dBm + + ) : ( + + )} + + + {device.name ?? "Unnamed tag"} + {developerMode ? ( + + {role ?? "unknown"} · {device.transportDeviceId} + + ) : null} - - {device.rssi} dBm - - )) - )} + ); + }) + )} + + + {developerMode ? ( + + + icon={Network} + title="Active network" + description="A selected network is verified on tags during connection." + value={snapshot.activeNetworkId ?? "none"} + choices={[ + { label: "None", value: "none" }, + ...snapshot.networks.map((network) => ({ + label: network.name, + value: network.id, + })), + ]} + onChange={(value) => + void run(() => + store.setActiveNetwork(value === "none" ? undefined : value), + ) + } + disabled={operation} + testID="active-network-setting" + /> + router.push("/(tabs)/settings/networks" as never)} + testID="network-management-link" + /> + {snapshot.rememberedTag ? ( + + + + setLabelEdit({ + deviceId: snapshot.rememberedTag?.id, + value, + }) + } + /> + + + + + ) : null} ) : null} @@ -237,7 +292,7 @@ export function TagConnectionScreen() { - Move closer to the tag, verify Bluetooth is enabled, then reconnect. + Move closer, verify Bluetooth is available, and try again. ) : null} @@ -245,8 +300,6 @@ export function TagConnectionScreen() { ); } -function connectionIcon(state: string) { - if (state === "connected") return BluetoothConnected; - if (state === "disconnected" || state === "error") return BluetoothOff; - return Bluetooth; +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); } diff --git a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts index 8ab2af30..57d86f36 100644 --- a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts +++ b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts @@ -104,6 +104,11 @@ export function useAnchorEditorController(anchorId: string) { mode === "marching" ? validateMarchingAnchorDraft(marchingDraft, fieldPreset) : validateStandardAnchorDraft(standardDraft, fieldPreset); + const canWritePosition = Boolean( + anchor && + (pans.connectionState === "connected" || + (pans.activeNetworkId && anchor.networkId === pans.activeNetworkId)), + ); const setMode = (nextMode: AnchorEditorMode) => { if (nextMode === mode) return; @@ -183,6 +188,7 @@ export function useAnchorEditorController(anchorId: string) { return { developerModeEnabled: settings.settings.developerModeEnabled, connectionState: pans.connectionState, + canWritePosition, anchor, fieldPreset, mode, diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts new file mode 100644 index 00000000..6adaaa9c --- /dev/null +++ b/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts @@ -0,0 +1,168 @@ +import type { FusedPositionOutput, FieldPoint } from "@eight2five/mobile/field"; +import type { + DeviceMotionAdapter, + DeviceMotionSample, +} from "@eight2five/mobile/motion"; +import type { PansPositionStreamSample } from "@eight2five/mobile/pans-manager"; +import type { SharedValue } from "react-native-reanimated"; + +import { + INITIAL_MOBILE_PANS_SNAPSHOT, + type MobilePansSnapshot, +} from "../mobile-pans-model"; +import { MobilePansPositionPublisher } from "../mobile-pans-position-publisher"; + +jest.mock("expo-pans-ble-api", () => ({})); +jest.mock("react-native-worklets", () => ({ + ...jest.requireActual("react-native-worklets/lib/module/mock"), + scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) => + callback(...args), +})); +jest.mock("react-native-reanimated", () => + jest.requireActual("react-native-reanimated/mock"), +); +jest.mock( + "@shopify/react-native-skia", + () => ({ + Canvas: () => null, + Fill: () => null, + Group: () => null, + Path: () => null, + Circle: () => null, + Line: () => null, + Rect: () => null, + useFont: () => ({}), + vec: (x: number, y: number) => ({ x, y }), + }), + { virtual: true }, +); + +describe("MobilePansPositionPublisher", () => { + test("publishes UWB-first fused output to both shared values and HUD state", async () => { + let snapshot: MobilePansSnapshot = { + ...INITIAL_MOBILE_PANS_SNAPSHOT, + connectionState: "connected", + }; + let motionListener!: (sample: DeviceMotionSample) => void; + const adapter: DeviceMotionAdapter = { + start: jest.fn(async (listener) => { + motionListener = listener; + return "active"; + }), + stop: jest.fn(), + }; + const positionValue = { value: null } as SharedValue; + const fusionValue = { + value: null, + } as SharedValue; + const publisher = new MobilePansPositionPublisher( + { + staleAfterMs: 2_500, + schedule: setTimeout, + cancel: clearTimeout, + isConnectionCurrent: () => true, + getSnapshot: () => snapshot, + publish: (next) => { + snapshot = next; + }, + }, + { motionAdapter: adapter, motionInterpolationEnabled: true }, + ); + publisher.attachPositionValue(positionValue); + publisher.attachFusionValue(fusionValue); + await publisher.startMotion(1); + + publisher.receiveSample(positionSample(1_000, 0), 1); + publisher.receiveSample(positionSample(2_000, 1), 1); + motionListener({ + receivedAt: 2_200, + acceleration: { x: 2, y: 0, z: 0 }, + rotationRate: { x: 0, y: 0, z: 0 }, + }); + + expect(positionValue.value).toEqual({ xMeters: 0.78, yMeters: 0 }); + expect(fusionValue.value).toMatchObject({ + source: "motion-predicted", + interpolationActive: true, + freshnessMs: 200, + lastUwbAt: 2_000, + }); + expect(snapshot.livePosition).toMatchObject({ + position: { xMeters: 0.78, yMeters: 0 }, + source: "motion-predicted", + interpolationActive: true, + lastUwbPosition: { xMeters: 1, yMeters: 0 }, + lastUwbAt: 2_000, + }); + expect(snapshot.rawPosition).toEqual({ + xMeters: 1, + yMeters: 0, + zMeters: 0, + }); + publisher.setMotionInterpolationEnabled(false); + expect(positionValue.value).toEqual({ xMeters: 1, yMeters: 0 }); + expect(fusionValue.value).toMatchObject({ + source: "uwb", + interpolationActive: false, + position: { xMeters: 1, yMeters: 0 }, + }); + publisher.dispose(); + }); + + test("bypasses the adapter and keeps raw UWB output when the preference is off", async () => { + let snapshot: MobilePansSnapshot = { + ...INITIAL_MOBILE_PANS_SNAPSHOT, + connectionState: "connected", + }; + const adapter: DeviceMotionAdapter = { + start: jest.fn(async () => "active"), + stop: jest.fn(), + }; + const fusionValue = { + value: null, + } as SharedValue; + const publisher = new MobilePansPositionPublisher( + { + staleAfterMs: 2_500, + schedule: setTimeout, + cancel: clearTimeout, + isConnectionCurrent: () => true, + getSnapshot: () => snapshot, + publish: (next) => { + snapshot = next; + }, + }, + { motionAdapter: adapter, motionInterpolationEnabled: false }, + ); + publisher.attachFusionValue(fusionValue); + + await publisher.startMotion(1); + publisher.receiveSample(positionSample(1_000, 5), 1); + publisher.receiveSample(positionSample(2_000, 6), 1); + + expect(adapter.start).not.toHaveBeenCalled(); + expect(fusionValue.value).toMatchObject({ + position: { xMeters: 6, yMeters: 0 }, + source: "uwb", + interpolationActive: false, + }); + expect(snapshot.livePosition.interpolationActive).toBe(false); + publisher.dispose(); + }); +}); + +function positionSample( + receivedAt: number, + xMeters: number, +): PansPositionStreamSample { + return { + deviceId: "tag", + transportDeviceId: "transport-tag", + receivedAt, + source: "notification", + position: { xMeters, yMeters: 0, zMeters: 0, quality: 80 }, + distances: [], + diagnostics: [], + decoderDiagnostics: [], + }; +} diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts index d0f718b5..6aedfe1d 100644 --- a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts +++ b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts @@ -6,6 +6,7 @@ import type { DiscoveredDeviceSnapshot, ManagedDevice, PansPosition, + PansInspectionResult, PansPositionStreamSample, StartPansPositionStreamOptions, } from "@eight2five/mobile/pans-manager"; @@ -78,7 +79,7 @@ describe("MobilePansStore", () => { const first = store.connect(); const second = store.connect(); - await Promise.resolve(); + await flushPromises(); expect(harness.streamStart).toHaveBeenCalledTimes(1); start.resolve(); @@ -303,6 +304,8 @@ describe("MobilePansStore", () => { expect(await harness.repository.getDevice("anchor-1")).toMatchObject({ lastKnownConfig: { position: { ...position, quality: 100 } }, }); + await store.writeAnchorPosition("anchor-1", position); + expect(harness.configurationApply).toHaveBeenCalledTimes(1); harness.configurationApply.mockRejectedValueOnce(new Error("write failed")); await expect( @@ -313,6 +316,159 @@ describe("MobilePansStore", () => { ).not.toHaveProperty("position"); await store.dispose(); }); + + test("inspects and sparsely repairs the performer profile before streaming", async () => { + const harness = await createHarness(); + harness.configurationInspect.mockResolvedValueOnce( + correctTagInspection("selection"), + ); + harness.configurationInspect.mockResolvedValueOnce({ + ...correctTagInspection("selected"), + operationMode: { + ...correctTagInspection("selected").operationMode, + ledEnabled: false, + }, + }); + harness.configurationApply.mockResolvedValueOnce({ + deviceId: "selected", + transportDeviceId: DISCOVERY.transportDeviceId, + outcome: "verified", + writes: [ + { + field: "ledEnabled", + status: "verified", + requested: true, + actual: true, + }, + ], + warnings: [], + } as never); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + + expect(harness.configurationApply).toHaveBeenCalledWith( + expect.any(String), + { ledEnabled: true }, + ); + expect(harness.streamStart).toHaveBeenCalledTimes(1); + await store.dispose(); + }); + + test("matches PAN only when Developer Mode has an active network", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + const network = await store.createNetwork("Field", 42); + await store.setActiveNetwork(network.id); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + + expect(harness.commissioningAssign).toHaveBeenCalledWith({ + deviceId: expect.any(String), + targetNetworkId: network.id, + }); + await store.dispose(); + }); + + test("resets the developer RSSI override when Developer Mode is disabled", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + await store.setDiscoveryRssiCutoff(-90); + await store.setDeveloperModeEnabled(false); + + expect((await harness.repository.getSettings())?.discoveryRssiCutoff).toBe( + -75, + ); + expect(store.getSnapshot().discoveryRssiCutoff).toBe(-75); + await store.dispose(); + }); + + test("creates, edits, selects, and deletes network profiles locally", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + const created = await store.createNetwork("Field", 100); + await store.setActiveNetwork(created.id); + await store.updateNetwork(created.id, { name: "Stadium", panId: 101 }); + expect(store.getSnapshot()).toMatchObject({ + activeNetworkId: created.id, + networks: [expect.objectContaining({ name: "Stadium", panId: 101 })], + }); + await store.deleteNetwork(created.id); + expect(store.getSnapshot().networks).toEqual([]); + expect(store.getSnapshot().activeNetworkId).toBeUndefined(); + await store.dispose(); + }); + + test("persists a directly discovered uncached anchor before editing", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + harness.emitDiscoveries([ANCHOR_DISCOVERY]); + harness.configurationInspect.mockResolvedValueOnce( + anchorInspection("anchor"), + ); + const saved = await store.persistDiscoveredAnchor( + ANCHOR_DISCOVERY.transportDeviceId, + ); + expect(saved).toMatchObject({ + role: "anchor", + transportDeviceId: ANCHOR_DISCOVERY.transportDeviceId, + }); + expect(store.getSnapshot().knownAnchors).toContainEqual( + expect.objectContaining({ id: saved.id }), + ); + await store.dispose(); + }); + + test("sets a reachable initiator and reports unreachable prior initiators", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + const network = await store.createNetwork("Field", 55); + await store.setActiveNetwork(network.id); + await harness.repository.saveDevice({ + ...managedAnchor("new-initiator", network.id), + lastKnownConfig: { ...anchorConfig(), initiatorEnabled: false }, + }); + await harness.repository.saveDevice({ + ...managedAnchor("old-initiator", network.id), + lastKnownConfig: { ...anchorConfig(), initiatorEnabled: true }, + }); + harness.configurationInspect.mockResolvedValueOnce({ + ...anchorInspection("new-initiator"), + operationMode: { + ...anchorInspection("new-initiator").operationMode, + initiatorEnabled: true, + }, + }); + await store.setAnchorInitiator("new-initiator"); + expect(harness.configurationApply).toHaveBeenCalledWith("new-initiator", { + initiatorEnabled: true, + }); + expect(store.getSnapshot().commissioningWarning).toContain("unreachable"); + await store.dispose(); + }); }); async function createHarness( @@ -368,7 +524,17 @@ async function createHarness( }; }, ); - const configurationInspect = jest.fn(async (_deviceId: string) => ({})); + const configurationInspect = jest.fn, [string]>( + async (deviceId: string) => correctTagInspection(deviceId), + ); + const commissioningAssign = jest.fn( + async ({ deviceId, targetNetworkId }) => ({ + deviceId, + targetNetworkId, + stage: "complete" as const, + outcome: "assigned" as const, + }), + ); const runtime = { repository, discovery: { @@ -405,6 +571,9 @@ async function createHarness( applyConfigurationDiff: configurationApply, inspectAndCache: configurationInspect, }, + commissioning: { + assignDeviceToNetworkProfile: commissioningAssign, + }, diagnostics: {}, close: jest.fn(async () => undefined), } as unknown as MobilePansRuntime; @@ -414,6 +583,7 @@ async function createHarness( streamStart, configurationApply, configurationInspect, + commissioningAssign, emitDiscoveries(next: DiscoveredDeviceSnapshot[]) { discoveries = next; for (const listener of discoveryListeners) listener(discoveries); @@ -427,6 +597,42 @@ async function createHarness( }; } +function correctTagInspection(deviceId: string): PansInspectionResult { + return { + deviceId, + transportDeviceId: DISCOVERY.transportDeviceId, + inspectedAt: 1, + operationMode: { + role: "tag" as const, + uwbMode: "active" as const, + selectedFirmware: 1 as const, + accelerometerEnabled: false, + ledEnabled: true, + firmwareUpdateEnabled: true, + initiatorEnabled: false, + lowPowerModeEnabled: false, + locationEngineEnabled: true, + raw: [0, 0] as [number, number], + }, + locationDataMode: 2 as const, + warnings: [], + }; +} + +function anchorInspection(deviceId: string): PansInspectionResult { + return { + ...correctTagInspection(deviceId), + operationMode: { + ...correctTagInspection(deviceId).operationMode, + role: "anchor" as const, + initiatorEnabled: false, + lowPowerModeEnabled: false, + locationEngineEnabled: false, + }, + locationDataMode: undefined, + }; +} + function managedAnchor(id: string, networkId?: string): ManagedDevice { return { id, @@ -498,7 +704,5 @@ function deferred() { } async function flushPromises(): Promise { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + for (let index = 0; index < 12; index += 1) await Promise.resolve(); } diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts new file mode 100644 index 00000000..97d3e4cc --- /dev/null +++ b/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts @@ -0,0 +1,69 @@ +import type { DiscoveredDeviceSnapshot } from "@eight2five/mobile/pans-manager"; +import { + connectionStatusViewModel, + selectVisibleDiscoveries, + signalStrengthForRssi, +} from "../mobile-pans-ui"; + +const tag = discovery("tag", "tag", -60); +const anchor = discovery("anchor", "anchor", -55); + +describe("mobile PANS UI selectors", () => { + test.each([ + ["connected", "Connected", "connected", false], + ["scanning", "Searching", "searching", true], + ["connecting", "Connecting", "connecting", true], + ["reconnecting", "Reconnecting", "connecting", true], + ["disconnected", "Disconnected", "disconnected", false], + ["error", "Connection error", "error", false], + ] as const)("maps %s status", (state, label, icon, animated) => { + expect(connectionStatusViewModel(state)).toMatchObject({ + label, + icon, + animated, + }); + }); + + test("ordinary mode filters anchors and weak advertisements", () => { + expect( + selectVisibleDiscoveries( + [tag, anchor, { ...tag, transportDeviceId: "weak", rssi: -80 }], + { + developerMode: false, + cutoff: -75, + }, + ).map((item) => item.transportDeviceId), + ).toEqual(["tag"]); + }); + + test("developer mode includes anchors and sorts by raw RSSI", () => { + expect( + selectVisibleDiscoveries([tag, anchor], { + developerMode: true, + cutoff: -75, + }).map((item) => item.transportDeviceId), + ).toEqual(["anchor", "tag"]); + }); + + test("derives signal bands relative to cutoff", () => { + expect(signalStrengthForRssi(-75, -75)).toBe("low"); + expect(signalStrengthForRssi(-65, -75)).toBe("medium"); + expect(signalStrengthForRssi(-50, -75)).toBe("high"); + expect(signalStrengthForRssi(-35, -75)).toBe("full"); + }); +}); + +function discovery( + transportDeviceId: string, + role: "tag" | "anchor", + rssi: number, +): DiscoveredDeviceSnapshot { + return { + transportDeviceId, + name: transportDeviceId, + rssi, + lastSeenAt: 1, + compatibility: "compatible", + presence: { role } as never, + }; +} diff --git a/apps/mobile/src/pans/mobile-pans-connection-controller.ts b/apps/mobile/src/pans/mobile-pans-connection-controller.ts index 9173e17c..884d9642 100644 --- a/apps/mobile/src/pans/mobile-pans-connection-controller.ts +++ b/apps/mobile/src/pans/mobile-pans-connection-controller.ts @@ -31,6 +31,7 @@ interface ConnectionControllerHost { state: TagConnectionState, changes?: Partial, ): void; + prepareTagForStreaming(): Promise; } /** Coordinates one connection attempt and one bounded reconnect loop. */ @@ -83,6 +84,7 @@ export class MobilePansConnectionController { async disconnect(): Promise { this.wantsConnection = false; this.invalidateConnection(); + this.host.positionPublisher.stopMotion(); const runtime = this.requireRuntime(); await Promise.allSettled([runtime.stream.stop(), runtime.discovery.stop()]); const rememberedTag = this.host.getRememberedTag(); @@ -107,6 +109,7 @@ export class MobilePansConnectionController { runtime.discovery.stop(), ]); } + this.host.positionPublisher.stopMotion(); if (this.wantsConnection) { this.host.publishState("reconnecting", { livePosition: staleLivePosition( @@ -126,6 +129,7 @@ export class MobilePansConnectionController { this.connectionGeneration += 1; this.host.positionPublisher.resetStreamState(); this.host.positionPublisher.clearLiveMarker(); + this.host.positionPublisher.stopMotion(); this.host.publishState("reconnecting", { livePosition: staleLivePosition( this.host.getSnapshot().livePosition, @@ -166,6 +170,7 @@ export class MobilePansConnectionController { return; } this.host.positionPublisher.clearLiveMarker(); + this.host.positionPublisher.stopMotion(); this.host.publishState( this.wantsConnection ? "reconnecting" : "disconnected", { @@ -209,6 +214,9 @@ export class MobilePansConnectionController { const available = await this.ensureDiscovered(tag, generation); if (!this.isConnectionCurrent(generation)) return; this.host.publishState(state); + await runtime.discovery.stop(); + await this.host.prepareTagForStreaming(); + if (!this.isConnectionCurrent(generation)) return; this.host.positionPublisher.resetStreamState(); await runtime.stream.start({ deviceId: tag.id, @@ -227,6 +235,7 @@ export class MobilePansConnectionController { await runtime.stream.stop(); return; } + void this.host.positionPublisher.startMotion(generation); await runtime.discovery.stop().catch(() => undefined); this.host.publishState("connected", { livePosition: { diff --git a/apps/mobile/src/pans/mobile-pans-context.tsx b/apps/mobile/src/pans/mobile-pans-context.tsx index 403906c9..90a98340 100644 --- a/apps/mobile/src/pans/mobile-pans-context.tsx +++ b/apps/mobile/src/pans/mobile-pans-context.tsx @@ -1,7 +1,11 @@ import React from "react"; import { AppState, type AppStateStatus } from "react-native"; import { useSharedValue, type SharedValue } from "react-native-reanimated"; -import type { FieldPoint } from "@eight2five/mobile/field"; +import type { FieldPoint, FusedPositionOutput } from "@eight2five/mobile/field"; +import { + createExpoDeviceMotionAdapter, + type DeviceMotionAdapter, +} from "@eight2five/mobile/motion"; import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; import { MobilePansStore, type MobilePansSnapshot } from "./mobile-pans-store"; @@ -9,6 +13,7 @@ import { MobilePansStore, type MobilePansSnapshot } from "./mobile-pans-store"; interface MobilePansContextValue { readonly store: MobilePansStore; readonly positionValue: SharedValue; + readonly fusionValue: SharedValue; } const MobilePansContext = React.createContext( @@ -18,10 +23,16 @@ const MobilePansContext = React.createContext( export function MobilePansProvider({ children, store: injectedStore, + motionAdapter, + motionInterpolationEnabled, + developerModeEnabled, appState = AppState, }: { readonly children: React.ReactNode; readonly store?: MobilePansStore; + readonly motionAdapter?: DeviceMotionAdapter; + readonly motionInterpolationEnabled?: boolean; + readonly developerModeEnabled?: boolean; readonly appState?: { readonly currentState: AppStateStatus | null; addEventListener( @@ -30,12 +41,33 @@ export function MobilePansProvider({ ): { remove(): void }; }; }) { - const [ownedStore] = React.useState(() => new MobilePansStore()); + const [ownedStore] = React.useState( + () => + new MobilePansStore({ + motionAdapter: motionAdapter ?? createExpoDeviceMotionAdapter(), + motionInterpolationEnabled: motionInterpolationEnabled ?? false, + developerModeEnabled: developerModeEnabled ?? false, + }), + ); const store = injectedStore ?? ownedStore; const positionValue = useSharedValue(null); + const fusionValue = useSharedValue(null); + + React.useEffect(() => { + if (motionInterpolationEnabled !== undefined) { + store.setMotionInterpolationEnabled(motionInterpolationEnabled); + } + }, [motionInterpolationEnabled, store]); + + React.useEffect(() => { + if (developerModeEnabled !== undefined) { + void store.setDeveloperModeEnabled(developerModeEnabled); + } + }, [developerModeEnabled, store]); React.useEffect(() => { store.attachPositionValue(positionValue); + store.attachFusionValue(fusionValue); store.setForeground(appState.currentState === "active"); void store.initialize(); const subscription = appState.addEventListener("change", (state) => { @@ -45,11 +77,11 @@ export function MobilePansProvider({ subscription.remove(); void store.dispose(); }; - }, [appState, positionValue, store]); + }, [appState, fusionValue, positionValue, store]); const value = React.useMemo( - () => ({ store, positionValue }), - [positionValue, store], + () => ({ store, positionValue, fusionValue }), + [fusionValue, positionValue, store], ); return ( @@ -82,8 +114,9 @@ export function useFieldLivePosition() { () => ({ state: livePosition, positionValue: context.positionValue, + fusionValue: context.fusionValue, }), - [context.positionValue, livePosition], + [context.fusionValue, context.positionValue, livePosition], ); } diff --git a/apps/mobile/src/pans/mobile-pans-model.ts b/apps/mobile/src/pans/mobile-pans-model.ts index 9ddd5f43..6d7b2837 100644 --- a/apps/mobile/src/pans/mobile-pans-model.ts +++ b/apps/mobile/src/pans/mobile-pans-model.ts @@ -5,12 +5,17 @@ import type { PansDiagnosticsResult, PansPosition, PansPositionStreamCounters, + ManagedNetwork, +} from "@eight2five/mobile/pans-manager"; +import { + DEFAULT_DISCOVERY_RSSI_CUTOFF, + normalizeTransportDeviceId, } from "@eight2five/mobile/pans-manager"; -import { normalizeTransportDeviceId } from "@eight2five/mobile/pans-manager"; import type { FieldLivePositionState, FieldPoint, } from "@eight2five/mobile/field"; +import type { DeviceMotionAdapter } from "@eight2five/mobile/motion"; import type { CreateMobilePansRuntime } from "./mobile-pans-runtime"; @@ -37,18 +42,25 @@ export interface MobilePansSnapshot { readonly counters?: Readonly; readonly hardwareDiagnostics?: PansDiagnosticsResult; readonly knownAnchors: readonly ManagedDevice[]; + readonly networks: readonly ManagedNetwork[]; + readonly activeNetworkId?: string; + readonly discoveryRssiCutoff: number; + readonly commissioningWarning?: string; readonly diagnosticMessages: readonly string[]; readonly error?: ManagerError | Error; } export interface MobilePansStoreOptions { readonly createRuntime?: CreateMobilePansRuntime; + readonly motionAdapter?: DeviceMotionAdapter; + readonly motionInterpolationEnabled?: boolean; readonly now?: () => number; readonly schedule?: typeof setTimeout; readonly cancel?: typeof clearTimeout; readonly reconnectDelaysMs?: readonly number[]; readonly staleAfterMs?: number; readonly discoveryTimeoutMs?: number; + readonly developerModeEnabled?: boolean; } export const EMPTY_DISCOVERIES: readonly DiscoveredDeviceSnapshot[] = @@ -59,10 +71,16 @@ export const INITIAL_MOBILE_PANS_SNAPSHOT: MobilePansSnapshot = Object.freeze({ initialization: "loading", connectionState: "idle", discoveries: EMPTY_DISCOVERIES, - livePosition: Object.freeze({ connectionState: "idle", isStale: false }), + livePosition: Object.freeze({ + connectionState: "idle", + isStale: false, + interpolationActive: false, + }), effectiveUpdateRateHz: 0, diagnosticMessages: Object.freeze([]), knownAnchors: Object.freeze([]), + networks: Object.freeze([]), + discoveryRssiCutoff: DEFAULT_DISCOVERY_RSSI_CUTOFF, }); /** @@ -108,6 +126,7 @@ export function staleLivePosition( ...live, connectionState, isStale: Boolean(live.position), + interpolationActive: false, ...(errorMessage ? { errorMessage } : {}), }; } diff --git a/apps/mobile/src/pans/mobile-pans-position-publisher.ts b/apps/mobile/src/pans/mobile-pans-position-publisher.ts index 7ccd122a..e4b143ff 100644 --- a/apps/mobile/src/pans/mobile-pans-position-publisher.ts +++ b/apps/mobile/src/pans/mobile-pans-position-publisher.ts @@ -1,4 +1,9 @@ -import type { FieldPoint } from "@eight2five/mobile/field"; +import type { FieldPoint, FusedPositionOutput } from "@eight2five/mobile/field"; +import { + ConservativePositionFusion, + type DeviceMotionAdapter, + type DeviceMotionSample, +} from "@eight2five/mobile/motion"; import type { PansPositionStreamSample } from "@eight2five/mobile/pans-manager"; import type { SharedValue } from "react-native-reanimated"; @@ -19,14 +24,33 @@ interface PositionPublisherHost { publish(snapshot: MobilePansSnapshot): void; } +export interface MobilePansPositionPublisherOptions { + readonly motionAdapter?: DeviceMotionAdapter; + readonly motionInterpolationEnabled?: boolean; +} + /** Publishes high-rate samples to Skia and coalesced human-readable state to React. */ export class MobilePansPositionPublisher { private positionValue?: SharedValue; + private fusionValue?: SharedValue; private staleTimer?: ReturnType; - private lastHudPublicationAt = 0; + private lastHudPublicationAt = Number.NEGATIVE_INFINITY; private sampleTimes: number[] = []; + private readonly fusion = new ConservativePositionFusion(); + private readonly motionAdapter?: DeviceMotionAdapter; + private motionInterpolationEnabled: boolean; + private motionSensorActive = false; + private activeGeneration?: number; + private motionStartToken = 0; - constructor(private readonly host: PositionPublisherHost) {} + constructor( + private readonly host: PositionPublisherHost, + options: MobilePansPositionPublisherOptions = {}, + ) { + this.motionAdapter = options.motionAdapter; + this.motionInterpolationEnabled = + options.motionInterpolationEnabled ?? Boolean(options.motionAdapter); + } attachPositionValue(value: SharedValue): void { if (this.positionValue && this.positionValue !== value) { @@ -37,41 +61,146 @@ export class MobilePansPositionPublisher { value.value = live.isStale ? null : (live.position ?? null); } + attachFusionValue(value: SharedValue): void { + if (this.fusionValue && this.fusionValue !== value) { + this.fusionValue.value = null; + } + this.fusionValue = value; + const live = this.host.getSnapshot().livePosition; + if (live.isStale || !live.position) { + value.value = null; + return; + } + const fusedAt = live.receivedAt ?? live.lastUwbAt ?? 0; + value.value = { + position: live.position, + source: live.source ?? "uwb", + fusedAt, + freshnessMs: live.freshnessMs ?? 0, + lastUwbAt: live.lastUwbAt ?? fusedAt, + lastUwbPosition: live.lastUwbPosition ?? live.position, + interpolationActive: live.interpolationActive ?? false, + }; + } + + setMotionInterpolationEnabled(enabled: boolean): void { + if (this.motionInterpolationEnabled === enabled) return; + this.motionInterpolationEnabled = enabled; + if (!enabled) { + this.stopMotion(); + this.fusion.reset(); + this.publishUwbOnlyState(); + return; + } + if (this.activeGeneration !== undefined) { + void this.startMotion(this.activeGeneration); + } + } + + async startMotion(generation: number): Promise { + this.activeGeneration = generation; + this.stopMotionSubscription(); + this.fusion.setMotionSensorActive(false); + if (!this.motionInterpolationEnabled || !this.motionAdapter) return; + + const token = ++this.motionStartToken; + try { + const result = await this.motionAdapter.start((sample) => { + if ( + token !== this.motionStartToken || + this.activeGeneration !== generation || + !this.host.isConnectionCurrent(generation) + ) { + return; + } + this.receiveMotionSample(sample, generation); + }); + if ( + token !== this.motionStartToken || + this.activeGeneration !== generation || + !this.host.isConnectionCurrent(generation) || + !this.motionInterpolationEnabled + ) { + try { + this.motionAdapter.stop(); + } catch { + // A best-effort cleanup must not turn optional motion into a + // connection failure. + } + return; + } + this.motionSensorActive = result === "active"; + this.fusion.setMotionSensorActive(this.motionSensorActive); + } catch { + // Sensor permission/availability is optional. UWB continues as the sole + // source when a phone cannot provide DeviceMotion. + this.motionSensorActive = false; + this.fusion.setMotionSensorActive(false); + } + } + + stopMotion(): void { + ++this.motionStartToken; + this.activeGeneration = undefined; + this.stopMotionSubscription(); + this.fusion.setMotionSensorActive(false); + } + receiveSample(sample: PansPositionStreamSample, generation: number): void { if (!this.host.isConnectionCurrent(generation) || !sample.position) return; - const fieldPoint = pansPositionToFieldPoint(sample.position); - if (this.positionValue) this.positionValue.value = fieldPoint; const receivedAt = sample.receivedAt; - this.sampleTimes = this.sampleTimes.filter( - (time) => receivedAt - time <= 1_000, - ); - this.sampleTimes.push(receivedAt); - this.scheduleStale(generation); - if (receivedAt - this.lastHudPublicationAt < HUD_PUBLICATION_INTERVAL_MS) { + const fieldPoint = pansPositionToFieldPoint(sample.position); + if (!this.motionInterpolationEnabled) { + this.sampleTimes = this.sampleTimes.filter( + (time) => receivedAt - time <= 1_000, + ); + this.sampleTimes.push(receivedAt); + this.publishOutput( + createRawUwbOutput(fieldPoint, receivedAt), + generation, + { + acceptedUwb: true, + rawPosition: { + xMeters: sample.position.xMeters, + yMeters: sample.position.yMeters, + zMeters: sample.position.zMeters, + }, + }, + ); return; } - this.lastHudPublicationAt = receivedAt; - const snapshot = this.host.getSnapshot(); - this.host.publish({ - ...snapshot, - connectionState: "connected", - livePosition: { - connectionState: "connected", - position: fieldPoint, - receivedAt, - isStale: false, - }, + const output = this.fusion.acceptUwb({ + position: fieldPoint, + receivedAt, + }); + if (!output) return; + + const acceptedUwb = output.lastUwbAt === receivedAt; + if (acceptedUwb) { + this.sampleTimes = this.sampleTimes.filter( + (time) => receivedAt - time <= 1_000, + ); + this.sampleTimes.push(receivedAt); + } + this.publishOutput(output, generation, { + acceptedUwb, rawPosition: { xMeters: sample.position.xMeters, yMeters: sample.position.yMeters, zMeters: sample.position.zMeters, }, - lastUpdateAt: receivedAt, - effectiveUpdateRateHz: effectiveRate(this.sampleTimes), - error: undefined, }); } + private receiveMotionSample( + sample: DeviceMotionSample, + generation: number, + ): void { + const output = this.fusion.acceptMotion(sample); + if (!output) return; + this.publishOutput(output, generation, { acceptedUwb: false }); + } + receiveDiagnostic(message: string, generation: number): void { if (!this.host.isConnectionCurrent(generation)) return; const snapshot = this.host.getSnapshot(); @@ -82,8 +211,10 @@ export class MobilePansPositionPublisher { } resetStreamState(): void { + this.stopMotion(); + this.fusion.reset(); this.sampleTimes = []; - this.lastHudPublicationAt = 0; + this.lastHudPublicationAt = Number.NEGATIVE_INFINITY; this.cancelStaleTimer(); const snapshot = this.host.getSnapshot(); if (snapshot.effectiveUpdateRateHz !== 0 || snapshot.counters) { @@ -97,37 +228,138 @@ export class MobilePansPositionPublisher { clearLiveMarker(): void { if (this.positionValue) this.positionValue.value = null; + if (this.fusionValue) this.fusionValue.value = null; } dispose(): void { + this.stopMotion(); this.cancelStaleTimer(); this.clearLiveMarker(); this.positionValue = undefined; + this.fusionValue = undefined; + } + + private publishOutput( + output: FusedPositionOutput, + generation: number, + options: { + readonly acceptedUwb: boolean; + readonly rawPosition?: MobilePansSnapshot["rawPosition"]; + }, + ): void { + if (!this.host.isConnectionCurrent(generation)) return; + const fieldPoint = output.position; + if (this.positionValue) this.positionValue.value = fieldPoint; + if (this.fusionValue) this.fusionValue.value = output; + this.scheduleStale(generation, output.freshnessMs); + if ( + output.fusedAt - this.lastHudPublicationAt < + HUD_PUBLICATION_INTERVAL_MS + ) { + return; + } + this.lastHudPublicationAt = output.fusedAt; + const snapshot = this.host.getSnapshot(); + this.host.publish({ + ...snapshot, + connectionState: "connected", + livePosition: { + connectionState: "connected", + position: fieldPoint, + receivedAt: output.fusedAt, + isStale: false, + source: output.source, + freshnessMs: output.freshnessMs, + lastUwbAt: output.lastUwbAt, + lastUwbPosition: output.lastUwbPosition, + interpolationActive: output.interpolationActive, + }, + ...(options.acceptedUwb && options.rawPosition + ? { rawPosition: options.rawPosition } + : {}), + lastUpdateAt: output.fusedAt, + effectiveUpdateRateHz: effectiveRate(this.sampleTimes), + error: undefined, + }); } - private scheduleStale(generation: number): void { + private scheduleStale(generation: number, freshnessMs: number): void { this.cancelStaleTimer(); - this.staleTimer = this.host.schedule(() => { - this.staleTimer = undefined; - if (!this.host.isConnectionCurrent(generation)) return; - this.clearLiveMarker(); - const snapshot = this.host.getSnapshot(); - this.host.publish({ - ...snapshot, - livePosition: staleLivePosition( - snapshot.livePosition, - snapshot.connectionState === "connected" - ? "connected" - : "reconnecting", - ), - }); - }, this.host.staleAfterMs); + this.staleTimer = this.host.schedule( + () => { + this.staleTimer = undefined; + if (!this.host.isConnectionCurrent(generation)) return; + this.clearLiveMarker(); + const snapshot = this.host.getSnapshot(); + this.host.publish({ + ...snapshot, + livePosition: staleLivePosition( + snapshot.livePosition, + snapshot.connectionState === "connected" + ? "connected" + : "reconnecting", + ), + }); + }, + Math.max(0, this.host.staleAfterMs - Math.max(0, freshnessMs)), + ); } private cancelStaleTimer(): void { if (this.staleTimer !== undefined) this.host.cancel(this.staleTimer); this.staleTimer = undefined; } + + private stopMotionSubscription(): void { + if (!this.motionSensorActive && !this.motionAdapter) return; + try { + this.motionAdapter?.stop(); + } catch { + // Sensor cleanup is best effort; UWB remains independently usable. + } + this.motionSensorActive = false; + } + + private publishUwbOnlyState(): void { + const snapshot = this.host.getSnapshot(); + const live = snapshot.livePosition; + if (!live.position) { + if (this.fusionValue) this.fusionValue.value = null; + return; + } + const position = live.lastUwbPosition ?? live.position; + const lastUwbAt = live.lastUwbAt ?? live.receivedAt ?? 0; + const fusedAt = live.receivedAt ?? lastUwbAt; + const freshnessMs = + live.lastUwbAt !== undefined ? Math.max(0, fusedAt - live.lastUwbAt) : 0; + const output: FusedPositionOutput = { + position, + source: "uwb", + fusedAt, + freshnessMs, + lastUwbAt, + lastUwbPosition: position, + interpolationActive: false, + }; + if (this.positionValue) + this.positionValue.value = live.isStale ? null : position; + if (this.fusionValue) this.fusionValue.value = live.isStale ? null : output; + this.host.publish({ + ...snapshot, + livePosition: { + ...live, + position, + receivedAt: fusedAt, + source: "uwb", + freshnessMs, + lastUwbAt: output.lastUwbAt, + lastUwbPosition: position, + interpolationActive: false, + isStale: live.isStale, + }, + lastUpdateAt: fusedAt, + }); + } } function effectiveRate(sampleTimes: readonly number[]): number { @@ -135,3 +367,18 @@ function effectiveRate(sampleTimes: readonly number[]): number { const elapsedMs = sampleTimes[sampleTimes.length - 1] - sampleTimes[0]; return elapsedMs > 0 ? ((sampleTimes.length - 1) * 1_000) / elapsedMs : 0; } + +function createRawUwbOutput( + position: FieldPoint, + receivedAt: number, +): FusedPositionOutput { + return { + position, + source: "uwb", + fusedAt: receivedAt, + freshnessMs: 0, + lastUwbAt: receivedAt, + lastUwbPosition: position, + interpolationActive: false, + }; +} diff --git a/apps/mobile/src/pans/mobile-pans-runtime.ts b/apps/mobile/src/pans/mobile-pans-runtime.ts index eae53333..d5097831 100644 --- a/apps/mobile/src/pans/mobile-pans-runtime.ts +++ b/apps/mobile/src/pans/mobile-pans-runtime.ts @@ -1,5 +1,6 @@ import type { PansConfigurationService, + PansCommissioningService, PansDeviceSessionManager, PansDiagnosticsService, PansDiscoveryService, @@ -13,6 +14,7 @@ export interface MobilePansRuntime { readonly sessions: PansDeviceSessionManager; readonly stream: PansPositionStreamService; readonly configuration: PansConfigurationService; + readonly commissioning: PansCommissioningService; readonly diagnostics: PansDiagnosticsService; close(): Promise; } @@ -37,14 +39,19 @@ export const createDefaultMobilePansRuntime: CreateMobilePansRuntime = undefined, settings.connectionTimeoutMs, ); + const configuration = new manager.PansConfigurationService( + sessions, + storage.repository, + ); return { repository: storage.repository, discovery, sessions, stream: new manager.PansPositionStreamService(sessions), - configuration: new manager.PansConfigurationService( - sessions, + configuration, + commissioning: new manager.PansCommissioningService( storage.repository, + configuration, ), diagnostics: new manager.PansDiagnosticsService(sessions), close: async () => { diff --git a/apps/mobile/src/pans/mobile-pans-store.ts b/apps/mobile/src/pans/mobile-pans-store.ts index f0e492e2..653aa5ea 100644 --- a/apps/mobile/src/pans/mobile-pans-store.ts +++ b/apps/mobile/src/pans/mobile-pans-store.ts @@ -1,13 +1,28 @@ import { + assertNetworkProfilePanId, + assertUniqueName, + assertValidLabel, + DEFAULT_MANAGED_NETWORK_SETTINGS, + DEFAULT_DISCOVERY_RSSI_CUTOFF, + deviceFromDiscovery, + diffPerformerTagProfile, + MAX_DISCOVERY_RSSI_CUTOFF, + MIN_DISCOVERY_RSSI_CUTOFF, normalizeManagerError, normalizePansManagerSettings, + normalizeTransportDeviceId, type DiscoveredDeviceSnapshot, type ManagedDevice, + type ManagedNetwork, ManagerError, type PansManagerSettings, type PansDiagnosticsResult, } from "@eight2five/mobile/pans-manager"; -import type { AnchorFieldPosition, FieldPoint } from "@eight2five/mobile/field"; +import type { + AnchorFieldPosition, + FieldPoint, + FusedPositionOutput, +} from "@eight2five/mobile/field"; import type { SharedValue } from "react-native-reanimated"; import { @@ -21,6 +36,7 @@ import { fieldConnectionState, INITIAL_MOBILE_PANS_SNAPSHOT, isSelectableTagDiscovery, + createLocalId, type MobilePansSnapshot, type MobilePansStoreOptions, type TagConnectionState, @@ -66,7 +82,9 @@ export class MobilePansStore { private rememberedTag?: ManagedDevice; private discoveries: readonly DiscoveredDeviceSnapshot[] = EMPTY_DISCOVERIES; private anchorWritePromise?: Promise; + private hardwareOperationPromise?: Promise; private manualDiscoveryRequested = false; + private developerModeEnabled: boolean; constructor(options: MobilePansStoreOptions = {}) { this.createRuntime = @@ -78,15 +96,22 @@ export class MobilePansStore { options.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS; this.staleAfterMs = options.staleAfterMs ?? 2_500; this.discoveryTimeoutMs = options.discoveryTimeoutMs ?? 10_000; - this.positionPublisher = new MobilePansPositionPublisher({ - staleAfterMs: this.staleAfterMs, - schedule: this.schedule, - cancel: this.cancel, - isConnectionCurrent: (generation) => - this.connectionController.isConnectionCurrent(generation), - getSnapshot: this.getSnapshot, - publish: (snapshot) => this.publish(snapshot), - }); + this.developerModeEnabled = options.developerModeEnabled ?? false; + this.positionPublisher = new MobilePansPositionPublisher( + { + staleAfterMs: this.staleAfterMs, + schedule: this.schedule, + cancel: this.cancel, + isConnectionCurrent: (generation) => + this.connectionController.isConnectionCurrent(generation), + getSnapshot: this.getSnapshot, + publish: (snapshot) => this.publish(snapshot), + }, + { + motionAdapter: options.motionAdapter, + motionInterpolationEnabled: options.motionInterpolationEnabled, + }, + ); this.connectionController = new MobilePansConnectionController({ reconnectDelaysMs: this.reconnectDelaysMs, discoveryTimeoutMs: this.discoveryTimeoutMs, @@ -99,6 +124,7 @@ export class MobilePansStore { getSnapshot: this.getSnapshot, publish: (snapshot) => this.publish(snapshot), publishState: (state, changes) => this.publishState(state, changes), + prepareTagForStreaming: () => this.prepareSelectedTagForStreaming(), }); } @@ -113,6 +139,46 @@ export class MobilePansStore { this.positionPublisher.attachPositionValue(value); } + attachFusionValue(value: SharedValue): void { + this.positionPublisher.attachFusionValue(value); + } + + setMotionInterpolationEnabled(enabled: boolean): void { + this.positionPublisher.setMotionInterpolationEnabled(enabled); + } + + async setDeveloperModeEnabled(enabled: boolean): Promise { + this.developerModeEnabled = enabled; + if ( + !enabled && + this.runtime && + this.snapshot.initialization === "ready" && + this.settings?.discoveryRssiCutoff !== DEFAULT_DISCOVERY_RSSI_CUTOFF + ) { + await this.setDiscoveryRssiCutoff(DEFAULT_DISCOVERY_RSSI_CUTOFF, true); + } + } + + async setDiscoveryRssiCutoff( + cutoff: number, + productionReset = false, + ): Promise { + if (!productionReset && !this.developerModeEnabled) { + throw new Error("Developer Mode is required to change signal filtering."); + } + if ( + !Number.isInteger(cutoff) || + cutoff < MIN_DISCOVERY_RSSI_CUTOFF || + cutoff > MAX_DISCOVERY_RSSI_CUTOFF + ) { + throw new Error( + `Signal cutoff must be an integer from ${MIN_DISCOVERY_RSSI_CUTOFF} to ${MAX_DISCOVERY_RSSI_CUTOFF} dBm.`, + ); + } + await this.saveManagerSettings({ discoveryRssiCutoff: cutoff }); + this.publish({ ...this.snapshot, discoveryRssiCutoff: cutoff }); + } + async initialize(): Promise { const generation = ++this.lifecycleGeneration; this.publish(INITIAL_MOBILE_PANS_SNAPSHOT); @@ -126,21 +192,45 @@ export class MobilePansStore { this.settings = normalizePansManagerSettings( await runtime.repository.getSettings(), ); + if ( + !this.developerModeEnabled && + this.settings.discoveryRssiCutoff !== DEFAULT_DISCOVERY_RSSI_CUTOFF + ) { + this.settings = normalizePansManagerSettings({ + ...this.settings, + discoveryRssiCutoff: DEFAULT_DISCOVERY_RSSI_CUTOFF, + }); + await runtime.repository.saveSettings(this.settings); + } this.rememberedTag = this.settings.rememberedTagDeviceId ? await runtime.repository.getDevice( this.settings.rememberedTagDeviceId, ) : undefined; - const devices = await runtime.repository.listDevices(); + const [devices, networks] = await Promise.all([ + runtime.repository.listDevices(), + runtime.repository.listNetworks(), + ]); const knownAnchors = sortedCachedAnchors(devices); if (!this.rememberedTag && this.settings.rememberedTagDeviceId) { await this.saveRememberedTag(undefined); } + if ( + this.settings.activeNetworkId && + !networks.some( + (network) => network.id === this.settings?.activeNetworkId, + ) + ) { + await this.saveManagerSettings({ activeNetworkId: undefined }); + } this.installRuntimeListeners(runtime, generation); this.connectionController.setWantsConnection(Boolean(this.rememberedTag)); this.publishState(this.rememberedTag ? "disconnected" : "idle", { initialization: "ready", knownAnchors, + networks, + activeNetworkId: this.settings.activeNetworkId, + discoveryRssiCutoff: this.settings.discoveryRssiCutoff, }); void this.connectionController.startReconnectLoop(); } catch (cause) { @@ -149,7 +239,11 @@ export class MobilePansStore { ...INITIAL_MOBILE_PANS_SNAPSHOT, initialization: "error", connectionState: "error", - livePosition: { connectionState: "error", isStale: false }, + livePosition: { + connectionState: "error", + isStale: false, + interpolationActive: false, + }, error: normalizeManagerError(cause, { operation: "initialize" }), }); } @@ -178,6 +272,11 @@ export class MobilePansStore { } } + async startTagDiscovery(): Promise { + if (this.snapshot.connectionState === "connected") return; + await this.startDiscovery(); + } + async stopDiscovery(): Promise { this.manualDiscoveryRequested = false; const runtime = this.runtime; @@ -217,6 +316,12 @@ export class MobilePansStore { }); } + async selectConfigureAndConnectTag(transportDeviceId: string): Promise { + await this.selectTag(transportDeviceId); + await this.stopDiscovery(); + await this.connect(); + } + async connect(): Promise { await this.connectionController.connect(false); } @@ -238,7 +343,11 @@ export class MobilePansStore { ...this.snapshot, connectionState: "idle", rememberedTag: undefined, - livePosition: { connectionState: "idle", isStale: false }, + livePosition: { + connectionState: "idle", + isStale: false, + interpolationActive: false, + }, rawPosition: undefined, lastUpdateAt: undefined, effectiveUpdateRateHz: 0, @@ -246,6 +355,298 @@ export class MobilePansStore { }); } + async clearSelectedTag(): Promise { + await this.forgetTag(); + } + + async renameSelectedTag(label: string): Promise { + if (!this.developerModeEnabled) { + throw new Error("Developer Mode is required to rename a tag."); + } + assertValidLabel(label); + const runtime = this.requireRuntime(); + const tag = this.rememberedTag; + if (!tag) throw new Error("Select a tag before changing its name."); + await this.runHardwareOperation(async () => { + const result = await runtime.configuration.applyConfigurationDiff( + tag.id, + { + label, + }, + ); + if ( + result.error || + result.writes.some( + (write) => write.status === "failed" || write.status === "mismatch", + ) + ) { + throw new Error( + result.error?.message ?? "The tag name could not be verified.", + ); + } + this.rememberedTag = (await runtime.repository.getDevice(tag.id)) ?? tag; + this.publish({ ...this.snapshot, rememberedTag: this.rememberedTag }); + }); + } + + async createNetwork(name: string, panId: number): Promise { + const runtime = this.requireDeveloperRuntime(); + const networks = await runtime.repository.listNetworks(); + assertUniqueName( + name, + networks.map((network) => network.name), + ); + assertNetworkProfilePanId(panId); + if (networks.some((network) => network.panId === panId)) { + throw new Error("A network with this PAN ID already exists."); + } + const now = this.now(); + const network = await runtime.repository.saveNetwork({ + id: createLocalId("network"), + name: name.trim(), + panId, + settings: DEFAULT_MANAGED_NETWORK_SETTINGS, + createdAt: now, + updatedAt: now, + }); + await this.refreshNetworksAndDevices(); + return network; + } + + async updateNetwork( + networkId: string, + changes: { readonly name: string; readonly panId: number }, + ): Promise { + const runtime = this.requireDeveloperRuntime(); + const [network, networks] = await Promise.all([ + runtime.repository.getNetwork(networkId), + runtime.repository.listNetworks(), + ]); + if (!network) throw new Error("The selected network no longer exists."); + assertUniqueName( + changes.name, + networks.filter((item) => item.id !== networkId).map((item) => item.name), + ); + assertNetworkProfilePanId(changes.panId); + if ( + networks.some( + (item) => item.id !== networkId && item.panId === changes.panId, + ) + ) { + throw new Error("A network with this PAN ID already exists."); + } + // Profile edits are app-local. Physical nodes change only via explicit assignment. + const saved = await runtime.repository.saveNetwork({ + ...network, + name: changes.name.trim(), + panId: changes.panId, + updatedAt: this.now(), + }); + await this.refreshNetworksAndDevices(); + return saved; + } + + async deleteNetwork(networkId: string): Promise { + const runtime = this.requireDeveloperRuntime(); + const devices = await runtime.repository.listNetworkDevices(networkId); + for (const device of devices) { + await runtime.repository.dissociateDevice( + networkId, + device.id, + this.now(), + ); + } + await runtime.repository.deleteNetwork(networkId); + if (this.settings?.activeNetworkId === networkId) { + await this.saveManagerSettings({ activeNetworkId: undefined }); + } + await this.refreshNetworksAndDevices(); + } + + async setActiveNetwork(networkId: string | undefined): Promise { + const runtime = this.requireDeveloperRuntime(); + if (networkId && !(await runtime.repository.getNetwork(networkId))) { + throw new Error("The selected network no longer exists."); + } + await this.saveManagerSettings({ activeNetworkId: networkId }); + this.publish({ ...this.snapshot, activeNetworkId: networkId }); + } + + async persistDiscoveredAnchor( + transportDeviceId: string, + confirmRoleChange = false, + ): Promise { + const runtime = this.requireDeveloperRuntime(); + const discovery = this.discoveries.find( + (item) => item.transportDeviceId === transportDeviceId, + ); + if ( + !discovery || + discovery.stale || + discovery.compatibility !== "compatible" + ) { + throw new Error("The selected device is no longer available."); + } + const advertisedRole = discovery.presence?.role; + if (advertisedRole !== "anchor" && !confirmRoleChange) { + throw new Error("Confirm changing this device from a tag to an anchor."); + } + const devices = await runtime.repository.listDevices(); + const existing = devices.find( + (device) => device.transportDeviceId === transportDeviceId, + ); + let saved = await runtime.repository.saveDevice({ + ...deviceFromDiscovery(discovery, existing, { + id: existing?.id ?? createLocalId("anchor"), + now: this.now(), + }), + role: advertisedRole ?? existing?.role, + }); + await runtime.discovery.stop(); + await this.runHardwareOperation(async () => { + const inspection = await runtime.configuration.inspectAndCache(saved.id); + if (inspection.operationMode.role !== "anchor") { + if (!confirmRoleChange) { + throw new Error( + "Confirm changing this device from a tag to an anchor.", + ); + } + const result = await runtime.configuration.applyConfigurationDiff( + saved.id, + { + role: "anchor", + uwbMode: "active", + ledEnabled: true, + firmwareUpdateEnabled: true, + initiatorEnabled: false, + }, + ); + if (result.error || result.inspected?.operationMode.role !== "anchor") { + throw new Error( + result.error?.message ?? "The anchor role could not be verified.", + ); + } + const reinspection = await runtime.configuration.inspectAndCache( + saved.id, + ); + if (reinspection.operationMode.role !== "anchor") { + throw new Error( + "The anchor role did not persist after reconnecting.", + ); + } + } + if (this.settings?.activeNetworkId) { + const assignment = + await runtime.commissioning.assignDeviceToNetworkProfile({ + deviceId: saved.id, + targetNetworkId: this.settings.activeNetworkId, + }); + if (assignment.outcome !== "assigned") { + throw new Error( + assignment.error?.message ?? + "The anchor network could not be verified.", + ); + } + } + }); + saved = (await runtime.repository.getDevice(saved.id)) ?? saved; + await this.refreshNetworksAndDevices(); + return saved; + } + + async assignDeviceToActiveNetwork(deviceId: string): Promise { + const runtime = this.requireDeveloperRuntime(); + const targetNetworkId = this.settings?.activeNetworkId; + if (!targetNetworkId) throw new Error("Select an active network first."); + await this.runHardwareOperation(async () => { + const result = await runtime.commissioning.assignDeviceToNetworkProfile({ + deviceId, + targetNetworkId, + }); + if (result.outcome !== "assigned") { + throw new Error(result.error?.message ?? "Network assignment failed."); + } + }); + await this.refreshNetworksAndDevices(); + } + + async setAnchorInitiator(anchorId: string): Promise { + const runtime = this.requireDeveloperRuntime(); + const activeNetworkId = this.settings?.activeNetworkId; + if (!activeNetworkId) throw new Error("Select an active network first."); + const anchors = ( + await runtime.repository.listNetworkDevices(activeNetworkId) + ).filter( + (device) => + device.lastKnownConfig?.role === "anchor" || device.role === "anchor", + ); + const selected = anchors.find((anchor) => anchor.id === anchorId); + if (!selected) + throw new Error("The selected anchor is not in the active network."); + const unreachable: string[] = []; + await this.runHardwareOperation(async () => { + const setResult = await runtime.configuration.applyConfigurationDiff( + anchorId, + { + initiatorEnabled: true, + }, + ); + if ( + setResult.error || + setResult.writes.some( + (write) => write.status === "mismatch" || write.status === "failed", + ) + ) { + throw new Error( + setResult.error?.message ?? "Initiator readback failed.", + ); + } + for (const prior of anchors.filter((anchor) => anchor.id !== anchorId)) { + const reachable = this.discoveries.some( + (item) => + !item.stale && + normalizeTransportDeviceId(item.transportDeviceId) === + normalizeTransportDeviceId(prior.transportDeviceId), + ); + if (!reachable) { + unreachable.push( + prior.lastKnownConfig?.label ?? prior.label ?? prior.id, + ); + continue; + } + const clearResult = await runtime.configuration.applyConfigurationDiff( + prior.id, + { initiatorEnabled: false }, + ); + if ( + clearResult.error || + clearResult.writes.some( + (write) => write.status === "mismatch" || write.status === "failed", + ) + ) { + unreachable.push( + prior.lastKnownConfig?.label ?? prior.label ?? prior.id, + ); + } + } + const verification = + await runtime.configuration.inspectAndCache(anchorId); + if ( + verification.operationMode.role !== "anchor" || + !verification.operationMode.initiatorEnabled + ) { + throw new Error("The selected initiator could not be verified."); + } + }); + await this.refreshNetworksAndDevices(); + this.publish({ + ...this.snapshot, + commissioningWarning: unreachable.length + ? `Initiator set, but ${unreachable.length} prior anchor${unreachable.length === 1 ? " was" : "s were"} unreachable and could not be verified.` + : undefined, + }); + } + async refreshDiagnostics(): Promise { const runtime = this.requireRuntime(); const tag = this.rememberedTag; @@ -255,21 +656,18 @@ export class MobilePansStore { ); } try { - await this.connectionController.pauseForOperation(); - const hardwareDiagnostics = await runtime.diagnostics.inspect( - tag.id, - tag.transportDeviceId, + const hardwareDiagnostics = await this.runHardwareOperation( + async () => + await runtime.diagnostics.inspect(tag.id, tag.transportDeviceId), ); this.publish({ ...this.snapshot, hardwareDiagnostics }); - await this.connectionController.resumeAfterOperation(); return hardwareDiagnostics; } catch (cause) { const error = normalizeManagerError(cause, { deviceId: tag.id, operation: "refresh diagnostics", }); - this.publishState("error", { error }); - void this.connectionController.startReconnectLoop(); + this.publish({ ...this.snapshot, error }); throw error; } } @@ -327,11 +725,6 @@ export class MobilePansStore { position: AnchorFieldPosition, ): Promise { const runtime = this.requireRuntime(); - if (!this.rememberedTag || this.snapshot.connectionState !== "connected") { - throw new Error( - "Connect the remembered PANS tag before writing an anchor position.", - ); - } const anchor = await runtime.repository.getDevice(anchorId); if ( !anchor || @@ -339,44 +732,202 @@ export class MobilePansStore { ) { throw new Error("The selected cached anchor does not exist."); } - if (!areDevicesNetworkAssociated(this.rememberedTag, anchor)) { + const cachedPosition = + anchor.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig.position + : undefined; + if ( + cachedPosition?.xMeters === position.xMeters && + cachedPosition.yMeters === position.yMeters && + cachedPosition.zMeters === position.zMeters && + cachedPosition.quality === 100 + ) { + return; + } + const activeNetworkMatch = + this.developerModeEnabled && + this.settings?.activeNetworkId !== undefined && + anchor.networkId === this.settings.activeNetworkId; + const connectedTagMatch = Boolean( + this.rememberedTag && + this.snapshot.connectionState === "connected" && + areDevicesNetworkAssociated(this.rememberedTag, anchor), + ); + if (!activeNetworkMatch && !connectedTagMatch) { throw new ManagerError( "INVALID_CONFIGURATION", - "The anchor is not verified on the remembered tag's PANS network.", + "The anchor is not verified on the active network.", { deviceId: anchor.id, operation: "write anchor position" }, ); } try { - await this.connectionController.pauseForOperation(); - const result = await runtime.configuration.applyConfigurationDiff( - anchor.id, - { - position: { ...position, quality: 100 }, - }, - ); - const write = result.writes.find((item) => item.field === "position"); - if (result.error || write?.status !== "written-unverified") { - throw new ManagerError( - result.error?.code ?? "WRITE_FAILED", - result.error?.message ?? "The anchor rejected the position write.", - { deviceId: anchor.id, operation: "write anchor position" }, + await this.runHardwareOperation(async () => { + const result = await runtime.configuration.applyConfigurationDiff( + anchor.id, + { + position: { ...position, quality: 100 }, + }, ); - } + const write = result.writes.find((item) => item.field === "position"); + if (result.error || write?.status !== "written-unverified") { + throw new ManagerError( + result.error?.code ?? "WRITE_FAILED", + result.error?.message ?? "The anchor rejected the position write.", + { deviceId: anchor.id, operation: "write anchor position" }, + ); + } + }); await this.refreshCachedAnchors(); } catch (cause) { const error = normalizeManagerError(cause, { deviceId: anchor.id, operation: "write anchor position", }); - this.publishState("error", { error }); throw error; - } finally { - try { - await this.connectionController.resumeAfterOperation(); - } catch { - // The reconnect action publishes its normalized failure. + } + } + + private async prepareSelectedTagForStreaming(): Promise { + const runtime = this.requireRuntime(); + const tag = this.rememberedTag; + if (!tag) throw new Error("Select a tag before connecting."); + await this.runHardwareOperation(async () => { + const inspection = await runtime.configuration.inspectAndCache(tag.id); + const profileChanges = diffPerformerTagProfile(inspection); + let reconnectVerificationRequired = false; + let locationModeWrittenUnverified = false; + if (Object.keys(profileChanges).length > 0) { + const configured = await runtime.configuration.applyConfigurationDiff( + tag.id, + profileChanges, + ); + if ( + configured.error || + configured.writes.some( + (write) => write.status === "failed" || write.status === "mismatch", + ) + ) { + throw new Error( + configured.error?.message ?? + "The performer tag profile could not be verified.", + ); + } + reconnectVerificationRequired = configured.writes.length > 0; + locationModeWrittenUnverified = configured.writes.some( + (write) => + write.field === "locationDataMode" && + write.status === "written-unverified", + ); + if (configured.inspected) { + const remaining = diffPerformerTagProfile(configured.inspected); + if ( + configured.inspected.locationDataMode === undefined && + locationModeWrittenUnverified + ) { + delete remaining.locationDataMode; + } + if (Object.keys(remaining).length > 0) { + throw new Error( + "The performer tag profile readback did not match.", + ); + } + } + } + const activeNetworkId = this.developerModeEnabled + ? this.settings?.activeNetworkId + : undefined; + if (activeNetworkId) { + const assignment = + await runtime.commissioning.assignDeviceToNetworkProfile({ + deviceId: tag.id, + targetNetworkId: activeNetworkId, + }); + if (assignment.outcome !== "assigned") { + throw new Error( + assignment.error?.message ?? + "The tag network could not be verified.", + ); + } + reconnectVerificationRequired = + reconnectVerificationRequired || + Boolean(assignment.configuration?.writes.length); + } + if (reconnectVerificationRequired) { + const reinspection = await runtime.configuration.inspectAndCache( + tag.id, + ); + const remaining = diffPerformerTagProfile(reinspection); + if ( + reinspection.locationDataMode === undefined && + locationModeWrittenUnverified + ) { + delete remaining.locationDataMode; + } + if (Object.keys(remaining).length > 0) { + throw new Error( + "The performer tag profile did not persist after reconnecting.", + ); + } + if (activeNetworkId) { + const activeNetwork = + await runtime.repository.getNetwork(activeNetworkId); + if (activeNetwork && reinspection.panId !== activeNetwork.panId) { + throw new Error( + "The active network did not persist after reconnecting.", + ); + } + } } + // The native PANS gateway exposes no hardware-reset command. Closing the + // serialized configuration session here and opening the stream session + // below provides the required reconnect/readback boundary. + this.rememberedTag = + (await runtime.repository.getDevice(tag.id)) ?? this.rememberedTag; + }); + this.publish({ ...this.snapshot, rememberedTag: this.rememberedTag }); + } + + private async runHardwareOperation(action: () => Promise): Promise { + if (this.hardwareOperationPromise) { + throw new ManagerError( + "OPERATION_CANCELLED", + "Another PANS hardware operation is already in progress.", + ); } + const wasConnected = this.snapshot.connectionState === "connected"; + const operation = (async () => { + if (wasConnected) await this.connectionController.pauseForOperation(); + try { + return await action(); + } finally { + if (wasConnected) { + await this.connectionController + .resumeAfterOperation() + .catch(() => undefined); + } + } + })(); + const tracked = operation.finally(() => { + if (this.hardwareOperationPromise === tracked) { + this.hardwareOperationPromise = undefined; + } + }); + this.hardwareOperationPromise = tracked; + return await tracked; + } + + private async refreshNetworksAndDevices(): Promise { + const runtime = this.requireRuntime(); + const [networks, devices] = await Promise.all([ + runtime.repository.listNetworks(), + runtime.repository.listDevices(), + ]); + this.publish({ + ...this.snapshot, + networks, + knownAnchors: sortedCachedAnchors(devices), + activeNetworkId: this.settings?.activeNetworkId, + }); } private installRuntimeListeners( @@ -422,12 +973,31 @@ export class MobilePansStore { } private async saveRememberedTag(deviceId: string | undefined): Promise { - const runtime = this.requireRuntime(); - this.settings = normalizePansManagerSettings({ + await this.saveManagerSettings({ rememberedTagDeviceId: deviceId }); + } + + private async saveManagerSettings( + changes: Partial, + ): Promise { + const runtime = this.runtime; + if (!runtime) throw new Error("PANS services are not ready."); + const candidate: Partial = { ...this.settings, - rememberedTagDeviceId: deviceId, - }); - if (!deviceId) delete this.settings.rememberedTagDeviceId; + ...changes, + }; + if ( + Object.prototype.hasOwnProperty.call(changes, "rememberedTagDeviceId") && + changes.rememberedTagDeviceId === undefined + ) { + delete candidate.rememberedTagDeviceId; + } + if ( + Object.prototype.hasOwnProperty.call(changes, "activeNetworkId") && + changes.activeNetworkId === undefined + ) { + delete candidate.activeNetworkId; + } + this.settings = normalizePansManagerSettings(candidate); await runtime.repository.saveSettings(this.settings); } @@ -467,6 +1037,13 @@ export class MobilePansStore { return this.runtime; } + private requireDeveloperRuntime(): MobilePansRuntime { + if (!this.developerModeEnabled) { + throw new Error("Developer Mode is required for network commissioning."); + } + return this.requireRuntime(); + } + private removeRuntimeListeners(): void { this.discoverySubscription?.remove(); this.discoveryErrorSubscription?.remove(); diff --git a/apps/mobile/src/pans/mobile-pans-ui.ts b/apps/mobile/src/pans/mobile-pans-ui.ts new file mode 100644 index 00000000..44b33711 --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-ui.ts @@ -0,0 +1,95 @@ +import type { DiscoveredDeviceSnapshot } from "@eight2five/mobile/pans-manager"; + +import type { TagConnectionState } from "./mobile-pans-model"; + +export type ConnectionStatusIcon = + | "connected" + | "searching" + | "connecting" + | "disconnected" + | "error"; + +export interface ConnectionStatusViewModel { + readonly label: string; + readonly icon: ConnectionStatusIcon; + readonly tone: "success" | "accent" | "muted" | "danger"; + readonly animated: boolean; +} + +export function connectionStatusViewModel( + state: TagConnectionState, +): ConnectionStatusViewModel { + switch (state) { + case "connected": + return { + label: "Connected", + icon: "connected", + tone: "success", + animated: false, + }; + case "scanning": + return { + label: "Searching", + icon: "searching", + tone: "accent", + animated: true, + }; + case "connecting": + return { + label: "Connecting", + icon: "connecting", + tone: "accent", + animated: true, + }; + case "reconnecting": + return { + label: "Reconnecting", + icon: "connecting", + tone: "accent", + animated: true, + }; + case "error": + return { + label: "Connection error", + icon: "error", + tone: "danger", + animated: false, + }; + case "idle": + case "disconnected": + return { + label: "Disconnected", + icon: "disconnected", + tone: "muted", + animated: false, + }; + } +} + +export type SignalStrength = "full" | "high" | "medium" | "low"; + +export function signalStrengthForRssi( + rssi: number, + cutoff: number, +): SignalStrength { + const aboveCutoff = rssi - cutoff; + if (aboveCutoff >= 40) return "full"; + if (aboveCutoff >= 25) return "high"; + if (aboveCutoff >= 10) return "medium"; + return "low"; +} + +export function selectVisibleDiscoveries( + discoveries: readonly DiscoveredDeviceSnapshot[], + options: { readonly developerMode: boolean; readonly cutoff: number }, +): readonly DiscoveredDeviceSnapshot[] { + return discoveries + .filter( + (device) => + !device.stale && + device.compatibility === "compatible" && + device.rssi >= options.cutoff && + (options.developerMode || device.presence?.role === "tag"), + ) + .sort((left, right) => right.rssi - left.rssi); +} diff --git a/apps/testbed/src/pans-manager/__tests__/manager-provider.test.tsx b/apps/testbed/src/pans-manager/__tests__/manager-provider.test.tsx index d10dcb42..e23f20c3 100644 --- a/apps/testbed/src/pans-manager/__tests__/manager-provider.test.tsx +++ b/apps/testbed/src/pans-manager/__tests__/manager-provider.test.tsx @@ -1171,6 +1171,7 @@ describe("PansManagerProvider", () => { connectionTimeoutMs: 10_000, positionLogMemoryCap: 1_000, positionLogFlushSize: 100, + discoveryRssiCutoff: -75, }); }); expect(settingsRenders).toBe(settingsBeforeSave + 1); diff --git a/apps/testbed/src/pans-manager/__tests__/manager-settings-screen.test.tsx b/apps/testbed/src/pans-manager/__tests__/manager-settings-screen.test.tsx index 53954542..a18edf44 100644 --- a/apps/testbed/src/pans-manager/__tests__/manager-settings-screen.test.tsx +++ b/apps/testbed/src/pans-manager/__tests__/manager-settings-screen.test.tsx @@ -153,5 +153,6 @@ function settings( connectionTimeoutMs, positionLogMemoryCap, positionLogFlushSize, + discoveryRssiCutoff: -75, }; } diff --git a/apps/testbed/src/pans-manager/manager-context.tsx b/apps/testbed/src/pans-manager/manager-context.tsx index 400fa438..1d0a8307 100644 --- a/apps/testbed/src/pans-manager/manager-context.tsx +++ b/apps/testbed/src/pans-manager/manager-context.tsx @@ -1669,6 +1669,7 @@ function managerSettingsWithDefaults( connectionTimeoutMs: 10_000, positionLogMemoryCap: 1_000, positionLogFlushSize: 100, + discoveryRssiCutoff: -75, ...compatible, }; } diff --git a/apps/testbed/src/pans-manager/screens/manager-settings-screen.tsx b/apps/testbed/src/pans-manager/screens/manager-settings-screen.tsx index e4176386..c1a00e2f 100644 --- a/apps/testbed/src/pans-manager/screens/manager-settings-screen.tsx +++ b/apps/testbed/src/pans-manager/screens/manager-settings-screen.tsx @@ -65,6 +65,7 @@ export function ManagerSettingsScreen() { } try { await saveManagerSettings({ + ...settings, discoveryStaleAfterMs: values[0], connectionTimeoutMs: values[1], positionLogMemoryCap: values[2], diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 79d828bd..6328dadc 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -10,6 +10,7 @@ "./field": "./src/field/index.ts", "./field/render": "./src/field/render/index.ts", "./drill": "./src/drill/index.ts", + "./motion": "./src/motion/index.ts", "./settings": "./src/settings/index.ts", "./storage": "./src/storage/index.ts" }, diff --git a/packages/mobile/src/field/live-position.ts b/packages/mobile/src/field/live-position.ts index 25c30ef1..e0605b4f 100644 --- a/packages/mobile/src/field/live-position.ts +++ b/packages/mobile/src/field/live-position.ts @@ -10,25 +10,55 @@ export type FieldConnectionState = | "disconnected" | "error"; +/** Describes which source produced the current field position. */ +export type FieldLivePositionSource = + | "uwb" + | "motion-predicted" + | "stationary-hold" + | "prediction-expired"; + +/** + * The high-rate output of the live-position fusion boundary. + * + * UWB is always the authority for `lastUwbPosition`. Motion may only produce + * a short prediction along the velocity learned from accepted UWB samples. + */ +export interface FusedPositionOutput { + readonly position: FieldPoint; + readonly source: FieldLivePositionSource; + readonly fusedAt: number; + readonly freshnessMs: number; + readonly lastUwbAt: number; + readonly lastUwbPosition: FieldPoint; + readonly interpolationActive: boolean; +} + export interface FieldLivePositionState { readonly connectionState: FieldConnectionState; readonly position?: FieldPoint; readonly receivedAt?: number; readonly isStale: boolean; + readonly source?: FieldLivePositionSource; + readonly freshnessMs?: number; + readonly lastUwbAt?: number; + readonly lastUwbPosition?: FieldPoint; + readonly interpolationActive?: boolean; readonly errorMessage?: string; } /** - * Thread 4 can update positionValue on its streaming cadence while replacing - * state only for connection, stale, and human-readable HUD changes. + * Thread 4 can update the shared values on the streaming cadence while + * replacing low-rate state only for connection, stale, and HUD changes. */ export interface FieldLivePositionInput { readonly state: FieldLivePositionState; readonly positionValue?: SharedValue; + readonly fusionValue?: SharedValue; } export const EMPTY_FIELD_LIVE_POSITION_STATE: FieldLivePositionState = Object.freeze({ connectionState: "idle", isStale: false, + interpolationActive: false, }); diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index ecae1bd9..b89f00c2 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -42,5 +42,6 @@ export * from "./field/render/field-render-tokens"; export * from "./field/render/field-overlay-types"; export * from "./field/render/drill-shape-policy"; export * from "./drill"; +export * from "./motion"; export * from "./settings"; export * from "./storage"; diff --git a/packages/mobile/src/motion/__tests__/device-motion.test.ts b/packages/mobile/src/motion/__tests__/device-motion.test.ts new file mode 100644 index 00000000..7a0f91c5 --- /dev/null +++ b/packages/mobile/src/motion/__tests__/device-motion.test.ts @@ -0,0 +1,79 @@ +import type { DeviceMotionMeasurement } from "expo-sensors"; +import { + DEVICE_MOTION_UPDATE_INTERVAL_MS, + ExpoDeviceMotionAdapter, +} from "../device-motion"; + +describe("ExpoDeviceMotionAdapter", () => { + test("falls back without requesting permission when the sensor is unavailable", async () => { + const sensor = createSensor({ available: false }); + const adapter = new ExpoDeviceMotionAdapter(sensor); + + await expect(adapter.start(jest.fn())).resolves.toBe("unavailable"); + expect(sensor.getPermissionsAsync).not.toHaveBeenCalled(); + expect(sensor.addListener).not.toHaveBeenCalled(); + }); + + test("falls back when permission remains denied", async () => { + const sensor = createSensor({ available: true, granted: false }); + const adapter = new ExpoDeviceMotionAdapter(sensor); + + await expect(adapter.start(jest.fn())).resolves.toBe("permission-denied"); + expect(sensor.requestPermissionsAsync).toHaveBeenCalledTimes(1); + expect(sensor.setUpdateInterval).not.toHaveBeenCalled(); + expect(sensor.addListener).not.toHaveBeenCalled(); + }); + + test("normalizes a measurement and removes the subscription", async () => { + let listener!: (measurement: DeviceMotionMeasurement) => void; + const sensor = createSensor({ available: true, granted: true }); + (sensor.addListener as jest.Mock).mockImplementation( + (next: (measurement: DeviceMotionMeasurement) => void) => { + listener = next; + return { remove: sensor.remove }; + }, + ); + const adapter = new ExpoDeviceMotionAdapter(sensor, () => 1234); + const received = jest.fn(); + + await expect(adapter.start(received)).resolves.toBe("active"); + expect(sensor.setUpdateInterval).toHaveBeenCalledWith( + DEVICE_MOTION_UPDATE_INTERVAL_MS, + ); + listener({ + acceleration: { x: 1, y: 2, z: 3, timestamp: 1 }, + accelerationIncludingGravity: { x: 0, y: 0, z: 0, timestamp: 1 }, + rotation: { alpha: 0, beta: 0, gamma: 0, timestamp: 1 }, + rotationRate: { alpha: 4, beta: 5, gamma: 6, timestamp: 1 }, + interval: 50, + orientation: 0, + }); + expect(received).toHaveBeenCalledWith({ + receivedAt: 1234, + acceleration: { x: 1, y: 2, z: 3 }, + rotationRate: { x: 4, y: 5, z: 6 }, + }); + + adapter.stop(); + expect(sensor.remove).toHaveBeenCalledTimes(1); + adapter.stop(); + expect(sensor.remove).toHaveBeenCalledTimes(1); + }); +}); + +function createSensor({ + available, + granted = true, +}: { + available: boolean; + granted?: boolean; +}) { + return { + isAvailableAsync: jest.fn(async () => available), + getPermissionsAsync: jest.fn(async () => ({ granted })), + requestPermissionsAsync: jest.fn(async () => ({ granted })), + setUpdateInterval: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + remove: jest.fn(), + }; +} diff --git a/packages/mobile/src/motion/__tests__/position-fusion.test.ts b/packages/mobile/src/motion/__tests__/position-fusion.test.ts new file mode 100644 index 00000000..e1fe43a1 --- /dev/null +++ b/packages/mobile/src/motion/__tests__/position-fusion.test.ts @@ -0,0 +1,191 @@ +import { + ConservativePositionFusion, + classifyMotionActivity, +} from "../position-fusion"; +import type { DeviceMotionSample } from "../device-motion"; + +describe("ConservativePositionFusion", () => { + test("predicts only along recent accepted UWB velocity and stops at 500ms", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + + expect( + fusion.acceptUwb({ + position: { xMeters: 0, yMeters: 0 }, + receivedAt: 0, + }), + ).toMatchObject({ + position: { xMeters: 0, yMeters: 0 }, + source: "uwb", + interpolationActive: false, + lastUwbAt: 0, + }); + fusion.acceptUwb({ + position: { xMeters: 1, yMeters: 0 }, + receivedAt: 1_000, + }); + + const predicted = fusion.acceptMotion(motion(1_200, 2)); + expect(predicted).toMatchObject({ + source: "motion-predicted", + interpolationActive: true, + freshnessMs: 200, + lastUwbAt: 1_000, + }); + expect(predicted?.position.xMeters).toBeCloseTo(0.78, 6); + + expect(fusion.acceptMotion(motion(1_501, 2))).toMatchObject({ + source: "prediction-expired", + interpolationActive: false, + freshnessMs: 501, + position: { xMeters: 0.65, yMeters: 0 }, + }); + }); + + test("stationary motion holds the last UWB fix and damps learned velocity", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + fusion.acceptUwb({ + position: { xMeters: 0, yMeters: 0 }, + receivedAt: 0, + }); + fusion.acceptUwb({ + position: { xMeters: 1, yMeters: 0 }, + receivedAt: 1_000, + }); + + const held = fusion.acceptMotion(motion(1_100, 0)); + expect(held).toMatchObject({ + source: "stationary-hold", + interpolationActive: false, + position: { xMeters: 0.65, yMeters: 0 }, + }); + + const resumed = fusion.acceptMotion(motion(1_200, 2)); + expect(resumed?.source).toBe("motion-predicted"); + expect(resumed?.position.xMeters).toBeCloseTo(0.676, 6); + }); + + test("rejects an implausible UWB jump instead of seeding prediction", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + fusion.acceptUwb({ + position: { xMeters: 0, yMeters: 0 }, + receivedAt: 0, + }); + const rejected = fusion.acceptUwb({ + position: { xMeters: 10, yMeters: 0 }, + receivedAt: 1_000, + }); + + expect(rejected).toMatchObject({ + source: "stationary-hold", + lastUwbAt: 0, + lastUwbPosition: { xMeters: 0, yMeters: 0 }, + }); + expect(fusion.acceptMotion(motion(200, 2))).toMatchObject({ + source: "uwb", + position: { xMeters: 0, yMeters: 0 }, + interpolationActive: false, + }); + }); + + test("accepts sustained movement after rejecting one impossible jump", () => { + const fusion = new ConservativePositionFusion(); + fusion.acceptUwb({ position: { xMeters: 0, yMeters: 0 }, receivedAt: 0 }); + expect( + fusion.acceptUwb({ + position: { xMeters: 10, yMeters: 0 }, + receivedAt: 1_000, + }), + ).toMatchObject({ lastUwbAt: 0 }); + expect( + fusion.acceptUwb({ + position: { xMeters: 10.5, yMeters: 0 }, + receivedAt: 1_100, + }), + ).toMatchObject({ + source: "uwb", + lastUwbAt: 1_100, + position: { xMeters: 10.5, yMeters: 0 }, + }); + }); + + test("a fresh UWB fix corrects a motion prediction", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + fusion.acceptUwb({ position: { xMeters: 0, yMeters: 0 }, receivedAt: 0 }); + fusion.acceptUwb({ + position: { xMeters: 1, yMeters: 0 }, + receivedAt: 1_000, + }); + expect(fusion.acceptMotion(motion(1_200, 2))?.source).toBe( + "motion-predicted", + ); + expect( + fusion.acceptUwb({ + position: { xMeters: 0.8, yMeters: 0 }, + receivedAt: 1_300, + }), + ).toMatchObject({ + source: "uwb", + lastUwbAt: 1_300, + interpolationActive: false, + }); + }); + + test("does not predict when motion is unavailable or inconclusive", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + fusion.acceptUwb({ + position: { xMeters: 0, yMeters: 0 }, + receivedAt: 0, + }); + fusion.acceptUwb({ + position: { xMeters: 1, yMeters: 0 }, + receivedAt: 1_000, + }); + + expect(fusion.acceptMotion(motion(1_200))).toMatchObject({ + source: "uwb", + position: { xMeters: 0.65, yMeters: 0 }, + interpolationActive: false, + }); + fusion.setMotionSensorActive(false); + expect(fusion.acceptMotion(motion(1_300, 2))).toMatchObject({ + source: "uwb", + interpolationActive: false, + }); + }); + + test("classifies only conservative activity magnitudes", () => { + expect(classifyMotionActivity(motion(1, 0))).toBe("stationary"); + expect(classifyMotionActivity(motion(1, 2))).toBe("moving"); + expect(classifyMotionActivity(motion(1))).toBe("unknown"); + expect( + classifyMotionActivity( + { + receivedAt: 1, + acceleration: null, + rotationRate: { x: 50, y: 0, z: 0 }, + }, + "stationary", + ), + ).toBe("unknown"); + }); +}); + +function motion( + receivedAt: number, + accelerationMagnitude?: number, +): DeviceMotionSample { + return { + receivedAt, + acceleration: + accelerationMagnitude === undefined + ? null + : { x: accelerationMagnitude, y: 0, z: 0 }, + rotationRate: + accelerationMagnitude === undefined ? null : { x: 0, y: 0, z: 0 }, + }; +} diff --git a/packages/mobile/src/motion/device-motion.ts b/packages/mobile/src/motion/device-motion.ts new file mode 100644 index 00000000..4a9958db --- /dev/null +++ b/packages/mobile/src/motion/device-motion.ts @@ -0,0 +1,145 @@ +import type { DeviceMotionMeasurement } from "expo-sensors"; + +export interface DeviceMotionVector { + readonly x: number; + readonly y: number; + readonly z: number; +} + +/** A timestamped, platform-neutral motion event used by the fusion boundary. */ +export interface DeviceMotionSample { + /** Receipt time in the same clock as PANS samples. */ + readonly receivedAt: number; + /** Linear acceleration only; never integrated or rotated into field space. */ + readonly acceleration: DeviceMotionVector | null; + /** Angular speed is used only as a conservative activity signal. */ + readonly rotationRate: DeviceMotionVector | null; +} + +export type DeviceMotionStartResult = + | "active" + | "unavailable" + | "permission-denied"; + +export interface DeviceMotionSubscription { + remove(): void; +} + +/** + * Injectable boundary around the native motion sensor. Implementations must + * not turn acceleration into a position estimate. + */ +export interface DeviceMotionAdapter { + start( + listener: (sample: DeviceMotionSample) => void, + ): Promise; + stop(): void; +} + +export interface DeviceMotionSensorLike { + isAvailableAsync(): Promise; + getPermissionsAsync(): Promise<{ readonly granted: boolean }>; + requestPermissionsAsync(): Promise<{ readonly granted: boolean }>; + setUpdateInterval(intervalMs: number): void; + addListener( + listener: (measurement: DeviceMotionMeasurement) => void, + ): DeviceMotionSubscription; +} + +export const DEVICE_MOTION_UPDATE_INTERVAL_MS = 100; + +/** + * Expo's DeviceMotion API is intentionally kept behind this adapter so Jest + * and non-native environments can inject a deterministic source. + */ +export class ExpoDeviceMotionAdapter implements DeviceMotionAdapter { + private subscription?: DeviceMotionSubscription; + private lifecycleToken = 0; + + constructor( + sensor: DeviceMotionSensorLike | undefined = undefined, + private readonly now: () => number = Date.now, + ) { + this.sensor = sensor; + } + + private sensor?: DeviceMotionSensorLike; + + async start( + listener: (sample: DeviceMotionSample) => void, + ): Promise { + if (this.subscription) return "active"; + const token = ++this.lifecycleToken; + + try { + const sensor = await this.getSensor(); + if (token !== this.lifecycleToken) return "unavailable"; + + if (!(await sensor.isAvailableAsync())) return "unavailable"; + if (token !== this.lifecycleToken) return "unavailable"; + + let permission = await sensor.getPermissionsAsync(); + if (token !== this.lifecycleToken) return "unavailable"; + if (!permission.granted) { + permission = await sensor.requestPermissionsAsync(); + } + if (token !== this.lifecycleToken) return "unavailable"; + if (!permission.granted) return "permission-denied"; + + sensor.setUpdateInterval(DEVICE_MOTION_UPDATE_INTERVAL_MS); + this.subscription = sensor.addListener((measurement) => { + listener(normalizeDeviceMotionMeasurement(measurement, this.now())); + }); + return "active"; + } catch { + this.stop(); + return "unavailable"; + } + } + + stop(): void { + ++this.lifecycleToken; + const subscription = this.subscription; + this.subscription = undefined; + subscription?.remove(); + } + + private async getSensor(): Promise { + if (this.sensor) return this.sensor; + const sensors = await import("expo-sensors"); + this.sensor = sensors.DeviceMotion; + return this.sensor; + } +} + +export function createExpoDeviceMotionAdapter( + sensor?: DeviceMotionSensorLike, + now: () => number = Date.now, +): DeviceMotionAdapter { + return new ExpoDeviceMotionAdapter(sensor, now); +} + +function normalizeDeviceMotionMeasurement( + measurement: DeviceMotionMeasurement, + receivedAt: number, +): DeviceMotionSample { + return { + receivedAt, + acceleration: normalizeVector(measurement.acceleration), + rotationRate: normalizeVector(measurement.rotationRate), + }; +} + +function normalizeVector( + value: + | { readonly x: number; readonly y: number; readonly z: number } + | { readonly alpha: number; readonly beta: number; readonly gamma: number } + | null + | undefined, +): DeviceMotionVector | null { + if (!value) return null; + if ("x" in value) { + return { x: value.x, y: value.y, z: value.z }; + } + return { x: value.alpha, y: value.beta, z: value.gamma }; +} diff --git a/packages/mobile/src/motion/index.ts b/packages/mobile/src/motion/index.ts new file mode 100644 index 00000000..b0acc45a --- /dev/null +++ b/packages/mobile/src/motion/index.ts @@ -0,0 +1,2 @@ +export * from "./device-motion"; +export * from "./position-fusion"; diff --git a/packages/mobile/src/motion/position-fusion.ts b/packages/mobile/src/motion/position-fusion.ts new file mode 100644 index 00000000..3b86f8cb --- /dev/null +++ b/packages/mobile/src/motion/position-fusion.ts @@ -0,0 +1,358 @@ +import type { + FieldLivePositionSource, + FusedPositionOutput, + FieldPoint, +} from "../field"; +import type { DeviceMotionSample } from "./device-motion"; + +export type MotionActivity = "unknown" | "stationary" | "moving"; + +export interface UwbFusionSample { + readonly position: FieldPoint; + readonly receivedAt: number; +} + +export interface ConservativePositionFusionOptions { + readonly predictionHorizonMs?: number; + readonly smoothingAlpha?: number; + readonly maxAcceptedSpeedMps?: number; + readonly maxJumpMeters?: number; +} + +export const MAX_MOTION_PREDICTION_MS = 500; +export const DEFAULT_POSITION_SMOOTHING_ALPHA = 0.65; +export const DEFAULT_MAX_ACCEPTED_UWB_SPEED_MPS = 6; +export const DEFAULT_MAX_UWB_JUMP_METERS = 4; + +const MIN_ACCEPTED_UWB_STEP_METERS = 0.75; +const MAX_VELOCITY_INTERVAL_MS = 1_500; + +/** + * Conservative UWB-first fusion. Device motion only gates a short prediction + * along velocity learned from accepted UWB positions. It is deliberately not + * an inertial-navigation implementation: no acceleration is integrated and no + * sensor vector is rotated into the field frame. + */ +export class ConservativePositionFusion { + private readonly predictionHorizonMs: number; + private readonly smoothingAlpha: number; + private readonly maxAcceptedSpeedMps: number; + private readonly maxJumpMeters: number; + private lastUwb?: { + readonly position: FieldPoint; + readonly receivedAt: number; + }; + private filteredPosition?: FieldPoint; + private velocity = { xMetersPerSecond: 0, yMetersPerSecond: 0 }; + private activity: MotionActivity = "unknown"; + private motionSensorActive = false; + private pendingJump?: UwbFusionSample; + + constructor(options: ConservativePositionFusionOptions = {}) { + this.predictionHorizonMs = boundedPositive( + options.predictionHorizonMs, + MAX_MOTION_PREDICTION_MS, + MAX_MOTION_PREDICTION_MS, + ); + this.smoothingAlpha = bounded( + options.smoothingAlpha, + DEFAULT_POSITION_SMOOTHING_ALPHA, + 0.1, + 1, + ); + this.maxAcceptedSpeedMps = boundedPositive( + options.maxAcceptedSpeedMps, + DEFAULT_MAX_ACCEPTED_UWB_SPEED_MPS, + DEFAULT_MAX_ACCEPTED_UWB_SPEED_MPS, + ); + this.maxJumpMeters = boundedPositive( + options.maxJumpMeters, + DEFAULT_MAX_UWB_JUMP_METERS, + DEFAULT_MAX_UWB_JUMP_METERS, + ); + } + + get lastUwbAt(): number | undefined { + return this.lastUwb?.receivedAt; + } + + get lastUwbPosition(): FieldPoint | undefined { + return this.lastUwb?.position; + } + + get motionActivity(): MotionActivity { + return this.activity; + } + + setMotionSensorActive(active: boolean): void { + this.motionSensorActive = active; + if (!active) this.activity = "unknown"; + } + + reset(): void { + this.lastUwb = undefined; + this.filteredPosition = undefined; + this.velocity = { xMetersPerSecond: 0, yMetersPerSecond: 0 }; + this.activity = "unknown"; + this.motionSensorActive = false; + this.pendingJump = undefined; + } + + acceptUwb(sample: UwbFusionSample): FusedPositionOutput | undefined { + if ( + !isFinitePoint(sample.position) || + !Number.isFinite(sample.receivedAt) + ) { + return undefined; + } + + const previous = this.lastUwb; + const previousFiltered = this.filteredPosition; + if (previous && sample.receivedAt <= previous.receivedAt) return undefined; + + if (previous && previousFiltered) { + const elapsedMs = sample.receivedAt - previous.receivedAt; + const jumpMeters = distance(previousFiltered, sample.position); + const allowedJumpMeters = Math.min( + this.maxJumpMeters, + Math.max( + MIN_ACCEPTED_UWB_STEP_METERS, + (elapsedMs / 1_000) * this.maxAcceptedSpeedMps + 0.35, + ), + ); + if (jumpMeters > allowedJumpMeters) { + const pending = this.pendingJump; + const sustainedMovement = + pending !== undefined && + sample.receivedAt > pending.receivedAt && + distance(pending.position, sample.position) <= + Math.max( + MIN_ACCEPTED_UWB_STEP_METERS, + ((sample.receivedAt - pending.receivedAt) / 1_000) * + this.maxAcceptedSpeedMps + + 0.35, + ); + if (sustainedMovement) { + this.filteredPosition = sample.position; + this.velocity = clampVelocity( + { + xMetersPerSecond: + (sample.position.xMeters - previousFiltered.xMeters) / + (elapsedMs / 1_000), + yMetersPerSecond: + (sample.position.yMeters - previousFiltered.yMeters) / + (elapsedMs / 1_000), + }, + this.maxAcceptedSpeedMps, + ); + this.pendingJump = undefined; + this.lastUwb = { + position: sample.position, + receivedAt: sample.receivedAt, + }; + return this.outputAt(sample.receivedAt, "uwb", false); + } + // Keep the last accepted UWB fix authoritative instead of allowing an + // isolated implausible frame to seed either smoothing or prediction. + // A second consistent fix is accepted so sustained real movement is + // never hidden indefinitely by the conservative jump gate. + this.pendingJump = sample; + return this.outputAt(sample.receivedAt, "stationary-hold", false); + } + + this.pendingJump = undefined; + + const filtered = + elapsedMs > MAX_VELOCITY_INTERVAL_MS + ? sample.position + : blend(previousFiltered, sample.position, this.smoothingAlpha); + this.filteredPosition = filtered; + this.velocity = + elapsedMs <= MAX_VELOCITY_INTERVAL_MS + ? clampVelocity( + { + xMetersPerSecond: + (filtered.xMeters - previousFiltered.xMeters) / + (elapsedMs / 1_000), + yMetersPerSecond: + (filtered.yMeters - previousFiltered.yMeters) / + (elapsedMs / 1_000), + }, + this.maxAcceptedSpeedMps, + ) + : { xMetersPerSecond: 0, yMetersPerSecond: 0 }; + } else { + this.filteredPosition = sample.position; + this.velocity = { xMetersPerSecond: 0, yMetersPerSecond: 0 }; + } + + this.lastUwb = { + position: sample.position, + receivedAt: sample.receivedAt, + }; + return this.outputAt(sample.receivedAt, "uwb", false); + } + + acceptMotion(sample: DeviceMotionSample): FusedPositionOutput | undefined { + if (!Number.isFinite(sample.receivedAt)) return undefined; + this.activity = classifyMotionActivity(sample, this.activity); + if (!this.lastUwb || !this.filteredPosition) return undefined; + + if (sample.receivedAt < this.lastUwb.receivedAt) { + return this.outputAt(sample.receivedAt, "uwb", false); + } + const ageMs = sample.receivedAt - this.lastUwb.receivedAt; + if (ageMs > this.predictionHorizonMs) { + return this.outputAt(sample.receivedAt, "prediction-expired", false); + } + if (this.activity === "stationary") { + // A stationary phone is evidence against extrapolation. Holding the last + // accepted UWB fix also damps velocity rather than inventing drift. + this.velocity = { + xMetersPerSecond: this.velocity.xMetersPerSecond * 0.2, + yMetersPerSecond: this.velocity.yMetersPerSecond * 0.2, + }; + return this.outputAt( + sample.receivedAt, + ageMs === 0 ? "uwb" : "stationary-hold", + false, + ); + } + + if ( + !this.motionSensorActive || + this.activity !== "moving" || + Math.hypot( + this.velocity.xMetersPerSecond, + this.velocity.yMetersPerSecond, + ) < 0.05 + ) { + return this.outputAt(sample.receivedAt, "uwb", false); + } + + const elapsedSeconds = Math.min(ageMs, this.predictionHorizonMs) / 1_000; + return this.outputAt(sample.receivedAt, "motion-predicted", true, { + xMeters: + this.filteredPosition.xMeters + + this.velocity.xMetersPerSecond * elapsedSeconds, + yMeters: + this.filteredPosition.yMeters + + this.velocity.yMetersPerSecond * elapsedSeconds, + }); + } + + private outputAt( + fusedAt: number, + source: FieldLivePositionSource, + interpolationActive: boolean, + position = this.filteredPosition, + ): FusedPositionOutput | undefined { + if (!this.lastUwb || !position) return undefined; + return { + position, + source, + fusedAt, + freshnessMs: Math.max(0, fusedAt - this.lastUwb.receivedAt), + lastUwbAt: this.lastUwb.receivedAt, + lastUwbPosition: this.lastUwb.position, + interpolationActive, + }; + } +} + +export function classifyMotionActivity( + sample: DeviceMotionSample, + previous: MotionActivity = "unknown", +): MotionActivity { + const acceleration = vectorMagnitude(sample.acceleration); + const rotationRate = vectorMagnitude(sample.rotationRate); + const hasSignal = acceleration !== undefined || rotationRate !== undefined; + if (!hasSignal) return "unknown"; + + // Rotation alone cannot establish field translation. It is deliberately + // treated as an uncertain signal rather than permission to extrapolate. + const moving = acceleration !== undefined && acceleration >= 1.25; + if (moving) return "moving"; + + if (rotationRate !== undefined && rotationRate >= 45) return "unknown"; + + const stationary = + (acceleration === undefined || acceleration <= 0.35) && + (rotationRate === undefined || rotationRate <= 10); + if (stationary) return "stationary"; + + return previous; +} + +function vectorMagnitude( + vector: { readonly x: number; readonly y: number; readonly z: number } | null, +): number | undefined { + if (!vector) return undefined; + if ( + !Number.isFinite(vector.x) || + !Number.isFinite(vector.y) || + !Number.isFinite(vector.z) + ) { + return undefined; + } + return Math.hypot(vector.x, vector.y, vector.z); +} + +function isFinitePoint(point: FieldPoint): boolean { + return Number.isFinite(point.xMeters) && Number.isFinite(point.yMeters); +} + +function distance(first: FieldPoint, second: FieldPoint): number { + return Math.hypot( + second.xMeters - first.xMeters, + second.yMeters - first.yMeters, + ); +} + +function blend( + first: FieldPoint, + second: FieldPoint, + alpha: number, +): FieldPoint { + return { + xMeters: first.xMeters + (second.xMeters - first.xMeters) * alpha, + yMeters: first.yMeters + (second.yMeters - first.yMeters) * alpha, + }; +} + +function clampVelocity( + velocity: { xMetersPerSecond: number; yMetersPerSecond: number }, + maximumSpeedMps: number, +): { xMetersPerSecond: number; yMetersPerSecond: number } { + const speed = Math.hypot( + velocity.xMetersPerSecond, + velocity.yMetersPerSecond, + ); + if (speed <= maximumSpeedMps || speed === 0) return velocity; + const scale = maximumSpeedMps / speed; + return { + xMetersPerSecond: velocity.xMetersPerSecond * scale, + yMetersPerSecond: velocity.yMetersPerSecond * scale, + }; +} + +function bounded( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, +): number { + return value !== undefined && Number.isFinite(value) + ? Math.min(maximum, Math.max(minimum, value)) + : fallback; +} + +function boundedPositive( + value: number | undefined, + fallback: number, + maximum: number, +): number { + return value !== undefined && Number.isFinite(value) && value > 0 + ? Math.min(maximum, value) + : fallback; +} diff --git a/packages/mobile/src/pans-manager/PansConfigurationService.ts b/packages/mobile/src/pans-manager/PansConfigurationService.ts index 10edc39c..cc9df510 100644 --- a/packages/mobile/src/pans-manager/PansConfigurationService.ts +++ b/packages/mobile/src/pans-manager/PansConfigurationService.ts @@ -836,11 +836,18 @@ function buildSparseModePatch( changes: HardwareDeviceChanges, current: PansInspectionResult["operationMode"], ): PansOperationModePatch { - return Object.fromEntries( + const explicit = Object.fromEntries( sparseModeFields(changes) .filter((field) => !Object.is(current[field.modeKey], field.requested)) .map((field) => [field.modeKey, field.requested]), ) as PansOperationModePatch; + if (changes.role === "tag" && current.initiatorEnabled) { + explicit.initiatorEnabled = false; + } else if (changes.role === "anchor") { + if (current.lowPowerModeEnabled) explicit.lowPowerModeEnabled = false; + if (current.locationEngineEnabled) explicit.locationEngineEnabled = false; + } + return explicit; } function validateHardwareChanges( diff --git a/packages/mobile/src/pans-manager/__tests__/performer-tag-profile.test.ts b/packages/mobile/src/pans-manager/__tests__/performer-tag-profile.test.ts new file mode 100644 index 00000000..585a5c8d --- /dev/null +++ b/packages/mobile/src/pans-manager/__tests__/performer-tag-profile.test.ts @@ -0,0 +1,66 @@ +import { + diffPerformerTagProfile, + PERFORMER_TAG_PROFILE, +} from "../performer-tag-profile"; +import type { PansInspectionResult } from "../types"; + +describe("performer tag profile", () => { + test("produces no writes for an already-correct tag", () => { + expect(diffPerformerTagProfile(inspection())).toEqual({}); + }); + + test("returns a sparse patch and preserves unrelated mode fields", () => { + const current = inspection({ + operationMode: { + ...inspection().operationMode, + ledEnabled: false, + selectedFirmware: 2, + raw: [17, 3], + }, + }); + expect(diffPerformerTagProfile(current)).toEqual({ ledEnabled: true }); + expect(diffPerformerTagProfile(current)).not.toHaveProperty( + "selectedFirmware", + ); + expect(PERFORMER_TAG_PROFILE.lowPowerModeEnabled).toBe(false); + }); + + test("maps stationary detection and location mode explicitly", () => { + expect( + diffPerformerTagProfile( + inspection({ + locationDataMode: 0, + operationMode: { + ...inspection().operationMode, + accelerometerEnabled: true, + }, + }), + ), + ).toEqual({ stationaryDetectionEnabled: false, locationDataMode: 2 }); + }); +}); + +function inspection( + changes: Partial = {}, +): PansInspectionResult { + return { + deviceId: "tag-1", + transportDeviceId: "transport-1", + inspectedAt: 1, + operationMode: { + role: "tag", + uwbMode: "active", + selectedFirmware: 1, + accelerometerEnabled: false, + ledEnabled: true, + firmwareUpdateEnabled: true, + initiatorEnabled: false, + lowPowerModeEnabled: false, + locationEngineEnabled: true, + raw: [0, 0], + }, + locationDataMode: 2, + warnings: [], + ...changes, + }; +} diff --git a/packages/mobile/src/pans-manager/index.ts b/packages/mobile/src/pans-manager/index.ts index d83ee289..7537b8ea 100644 --- a/packages/mobile/src/pans-manager/index.ts +++ b/packages/mobile/src/pans-manager/index.ts @@ -6,6 +6,7 @@ export * from "./map-units"; export * from "./device-sections"; export * from "./profile-matching"; export * from "./device-discovery"; +export * from "./performer-tag-profile"; export * from "./PansManagerRepository"; export * from "./InMemoryPansManagerRepository"; export * from "./SqlitePansManagerRepository"; diff --git a/packages/mobile/src/pans-manager/performer-tag-profile.ts b/packages/mobile/src/pans-manager/performer-tag-profile.ts new file mode 100644 index 00000000..b8d40b58 --- /dev/null +++ b/packages/mobile/src/pans-manager/performer-tag-profile.ts @@ -0,0 +1,37 @@ +import type { HardwareDeviceChanges, PansInspectionResult } from "./types"; + +/** Production fields owned by Eight2Five. Unlisted PANS fields are preserved. */ +export const PERFORMER_TAG_PROFILE = Object.freeze({ + role: "tag" as const, + uwbMode: "active" as const, + ledEnabled: true, + firmwareUpdateEnabled: true, + locationEngineEnabled: true, + // PANS exposes low-power; responsive mode is its inverse. + lowPowerModeEnabled: false, + stationaryDetectionEnabled: false, + locationDataMode: 2 as const, +}); + +/** Returns only profile-owned fields whose readable values differ. */ +export function diffPerformerTagProfile( + inspection: PansInspectionResult, +): HardwareDeviceChanges { + const mode = inspection.operationMode; + const current: Record = { + role: mode.role, + uwbMode: mode.uwbMode, + ledEnabled: mode.ledEnabled, + firmwareUpdateEnabled: mode.firmwareUpdateEnabled, + locationEngineEnabled: mode.locationEngineEnabled, + lowPowerModeEnabled: mode.lowPowerModeEnabled, + stationaryDetectionEnabled: mode.accelerometerEnabled, + locationDataMode: inspection.locationDataMode, + }; + return Object.fromEntries( + Object.entries(PERFORMER_TAG_PROFILE).filter( + ([field, requested]) => + !Object.is(current[field as keyof typeof current], requested), + ), + ) as HardwareDeviceChanges; +} diff --git a/packages/mobile/src/pans-manager/types.ts b/packages/mobile/src/pans-manager/types.ts index bfee6fda..4417b414 100644 --- a/packages/mobile/src/pans-manager/types.ts +++ b/packages/mobile/src/pans-manager/types.ts @@ -398,13 +398,22 @@ export interface PansManagerSettings { positionLogFlushSize: number; /** Stable local device identity selected by the performer app. */ rememberedTagDeviceId?: string; + /** Developer-only discovery override. Reset when Developer Mode is disabled. */ + discoveryRssiCutoff: number; + /** Durable selected profile; selecting a profile does not rewrite hardware. */ + activeNetworkId?: string; } +export const DEFAULT_DISCOVERY_RSSI_CUTOFF = -75; +export const MIN_DISCOVERY_RSSI_CUTOFF = -100; +export const MAX_DISCOVERY_RSSI_CUTOFF = -30; + export const DEFAULT_PANS_MANAGER_SETTINGS: PansManagerSettings = { discoveryStaleAfterMs: 10_000, connectionTimeoutMs: 10_000, positionLogMemoryCap: 1_000, positionLogFlushSize: 100, + discoveryRssiCutoff: DEFAULT_DISCOVERY_RSSI_CUTOFF, }; export function normalizePansManagerSettings( @@ -420,6 +429,21 @@ export function normalizePansManagerSettings( ) { delete compatible.rememberedTagDeviceId; } + if ( + compatible.activeNetworkId !== undefined && + (typeof compatible.activeNetworkId !== "string" || + !compatible.activeNetworkId.trim()) + ) { + delete compatible.activeNetworkId; + } + if ( + typeof compatible.discoveryRssiCutoff !== "number" || + !Number.isInteger(compatible.discoveryRssiCutoff) || + compatible.discoveryRssiCutoff < MIN_DISCOVERY_RSSI_CUTOFF || + compatible.discoveryRssiCutoff > MAX_DISCOVERY_RSSI_CUTOFF + ) { + compatible.discoveryRssiCutoff = DEFAULT_DISCOVERY_RSSI_CUTOFF; + } return { ...DEFAULT_PANS_MANAGER_SETTINGS, ...compatible }; } From 5dc89cdb67b85dbf33773fb1550c9d24324a8918 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 17:05:18 -0500 Subject: [PATCH 071/101] feat(mobile): add app info tab Expose reproducible version, native build, and Git metadata with links to the project and its MIT license. Keep the Info tab visible independently of drill features. --- apps/mobile/app.config.ts | 32 +++- apps/mobile/app/(tabs)/info/_layout.tsx | 23 +++ apps/mobile/app/(tabs)/info/index.tsx | 5 + apps/mobile/package.json | 2 +- .../info/__tests__/info-metadata.test.ts | 69 +++++++ .../info/__tests__/info-theme-assets.test.ts | 30 +++ .../mobile/src/features/info/info-metadata.ts | 73 ++++++++ apps/mobile/src/features/info/info-screen.tsx | 175 ++++++++++++++++++ .../src/features/info/info-theme-assets.ts | 20 ++ .../navigation/__tests__/mobile-tabs.test.ts | 14 ++ apps/mobile/src/navigation/mobile-tabs.ts | 10 +- package-lock.json | 2 +- 12 files changed, 451 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/app/(tabs)/info/_layout.tsx create mode 100644 apps/mobile/app/(tabs)/info/index.tsx create mode 100644 apps/mobile/src/features/info/__tests__/info-metadata.test.ts create mode 100644 apps/mobile/src/features/info/__tests__/info-theme-assets.test.ts create mode 100644 apps/mobile/src/features/info/info-metadata.ts create mode 100644 apps/mobile/src/features/info/info-screen.tsx create mode 100644 apps/mobile/src/features/info/info-theme-assets.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index bc60c800..a044af1e 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,10 +1,31 @@ +import { execFileSync } from "node:child_process"; import type { ExpoConfig } from "expo/config"; +function resolveGitSha(): string { + const injectedSha = ( + process.env.EIGHT2FIVE_GIT_SHA ?? + process.env.EAS_BUILD_GIT_COMMIT_HASH ?? + process.env.GITHUB_SHA + )?.trim(); + if (injectedSha) return injectedSha; + + try { + return execFileSync("git", ["rev-parse", "--short", "HEAD"], { + cwd: __dirname, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unknown"; + } +} + const buildId = process.env.E2F_BUILD_ID ?? process.env.EAS_BUILD_GIT_COMMIT_HASH ?? process.env.GITHUB_SHA ?? "local"; +const gitSha = resolveGitSha(); const requestedVersionCode = Number( process.env.E2F_ANDROID_VERSION_CODE ?? process.env.GITHUB_RUN_NUMBER ?? 1, ); @@ -12,6 +33,13 @@ const androidVersionCode = Number.isSafeInteger(requestedVersionCode) && requestedVersionCode > 0 ? requestedVersionCode : 1; +const requestedIosBuildNumber = Number( + process.env.E2F_IOS_BUILD_NUMBER ?? process.env.GITHUB_RUN_NUMBER ?? 1, +); +const iosBuildNumber = + Number.isSafeInteger(requestedIosBuildNumber) && requestedIosBuildNumber > 0 + ? String(requestedIosBuildNumber) + : "1"; const appVariant = process.env.APP_VARIANT; const isDevelopment = appVariant === "development"; @@ -35,7 +63,7 @@ const config: ExpoConfig = { slug: "eight2five", scheme: "eight2five", platforms: ["ios", "android"], - version: "0.0.0", + version: "0.1.0", // Field is the only route that opts into landscape; Drill and Settings // apply portrait locks through their nested native stacks. orientation: "default", @@ -43,6 +71,7 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", ios: { bundleIdentifier: appIdentifier, + buildNumber: iosBuildNumber, supportsTablet: false, icon: { light: "./assets/app-icons/mobile-ios-icon.png", @@ -121,6 +150,7 @@ const config: ExpoConfig = { }, extra: { buildId, + EIGHT2FIVE_GIT_SHA: gitSha, eas: { projectId: "a26bddc3-6439-460b-b15b-51143e499c8a", }, diff --git a/apps/mobile/app/(tabs)/info/_layout.tsx b/apps/mobile/app/(tabs)/info/_layout.tsx new file mode 100644 index 00000000..a828cdfa --- /dev/null +++ b/apps/mobile/app/(tabs)/info/_layout.tsx @@ -0,0 +1,23 @@ +import { Stack } from "expo-router"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; + +export default function InfoLayout() { + const theme = useEight2FiveTheme(); + + return ( + + + + ); +} diff --git a/apps/mobile/app/(tabs)/info/index.tsx b/apps/mobile/app/(tabs)/info/index.tsx new file mode 100644 index 00000000..81eaa151 --- /dev/null +++ b/apps/mobile/app/(tabs)/info/index.tsx @@ -0,0 +1,5 @@ +import { InfoScreen } from "../../../src/features/info/info-screen"; + +export default function InfoRoute() { + return ; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 36407992..46438a03 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "eight2five-mobile", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "expo-router/entry", "scripts": { diff --git a/apps/mobile/src/features/info/__tests__/info-metadata.test.ts b/apps/mobile/src/features/info/__tests__/info-metadata.test.ts new file mode 100644 index 00000000..402fac1d --- /dev/null +++ b/apps/mobile/src/features/info/__tests__/info-metadata.test.ts @@ -0,0 +1,69 @@ +import { + EIGHT2FIVE_APP_NAME, + EIGHT2FIVE_APP_VERSION, + EIGHT2FIVE_GITHUB_URL, + EIGHT2FIVE_LICENSE_URL, + INFO_UNAVAILABLE, + getMobileInfoMetadata, + getShortGitSha, +} from "../info-metadata"; + +describe("mobile info metadata", () => { + test("uses the iOS build number and shortens the injected SHA", () => { + expect( + getMobileInfoMetadata( + { + name: EIGHT2FIVE_APP_NAME, + version: EIGHT2FIVE_APP_VERSION, + ios: { buildNumber: "42" }, + android: { versionCode: 7 }, + extra: { + EIGHT2FIVE_GIT_SHA: "0123456789abcdef0123456789abcdef01234567", + }, + }, + "ios", + ), + ).toEqual({ + appName: EIGHT2FIVE_APP_NAME, + version: EIGHT2FIVE_APP_VERSION, + nativeBuildLabel: "iOS build number", + nativeBuildValue: "42", + gitSha: "0123456", + }); + }); + + test("uses the Android version code and exposes the external targets", () => { + const metadata = getMobileInfoMetadata( + { + android: { versionCode: 7 }, + extra: { EIGHT2FIVE_GIT_SHA: "abcdef1" }, + }, + "android", + ); + + expect(metadata.nativeBuildLabel).toBe("Android version code"); + expect(metadata.nativeBuildValue).toBe("7"); + expect(metadata.gitSha).toBe("abcdef1"); + expect(EIGHT2FIVE_LICENSE_URL).toBe( + `${EIGHT2FIVE_GITHUB_URL}/blob/main/LICENSE`, + ); + }); + + test("prefers the build identifier reported by the installed native app", () => { + expect( + getMobileInfoMetadata({ ios: { buildNumber: "1" } }, "ios", "84") + .nativeBuildValue, + ).toBe("84"); + }); + + test("does not fabricate missing or invalid build metadata", () => { + expect(getMobileInfoMetadata(undefined, "ios")).toMatchObject({ + appName: EIGHT2FIVE_APP_NAME, + version: EIGHT2FIVE_APP_VERSION, + nativeBuildValue: INFO_UNAVAILABLE, + gitSha: INFO_UNAVAILABLE, + }); + expect(getShortGitSha("local")).toBe(INFO_UNAVAILABLE); + expect(getShortGitSha("not-a-sha")).toBe(INFO_UNAVAILABLE); + }); +}); diff --git a/apps/mobile/src/features/info/__tests__/info-theme-assets.test.ts b/apps/mobile/src/features/info/__tests__/info-theme-assets.test.ts new file mode 100644 index 00000000..7bbbcdca --- /dev/null +++ b/apps/mobile/src/features/info/__tests__/info-theme-assets.test.ts @@ -0,0 +1,30 @@ +import { + INFO_SPLASH_ASSET_PATHS, + getInfoSplashAssetPath, +} from "../info-theme-assets"; + +describe("mobile info splash assets", () => { + test("selects the canonical light and dark asset for each native platform", () => { + expect(getInfoSplashAssetPath("light", "ios")).toBe( + "./assets/splash-icons/mobile-ios-splash-icon-light.png", + ); + expect(getInfoSplashAssetPath("dark", "ios")).toBe( + "./assets/splash-icons/mobile-ios-splash-icon-dark.png", + ); + expect(getInfoSplashAssetPath("light", "android")).toBe( + "./assets/splash-icons/mobile-ios-splash-icon-light.png", + ); + expect(getInfoSplashAssetPath("dark", "android")).toBe( + "./assets/splash-icons/mobile-ios-splash-icon-dark.png", + ); + }); + + test("keeps light and dark assets distinct on both platforms", () => { + expect(INFO_SPLASH_ASSET_PATHS.light.ios).not.toBe( + INFO_SPLASH_ASSET_PATHS.dark.ios, + ); + expect(INFO_SPLASH_ASSET_PATHS.light.android).not.toBe( + INFO_SPLASH_ASSET_PATHS.dark.android, + ); + }); +}); diff --git a/apps/mobile/src/features/info/info-metadata.ts b/apps/mobile/src/features/info/info-metadata.ts new file mode 100644 index 00000000..38ed3f96 --- /dev/null +++ b/apps/mobile/src/features/info/info-metadata.ts @@ -0,0 +1,73 @@ +export const EIGHT2FIVE_APP_NAME = "Eight2Five"; +export const EIGHT2FIVE_APP_VERSION = "0.1.0"; +export const EIGHT2FIVE_GITHUB_URL = "https://github.com/CDGuth/Eight2Five"; +export const EIGHT2FIVE_LICENSE_URL = `${EIGHT2FIVE_GITHUB_URL}/blob/main/LICENSE`; +export const INFO_UNAVAILABLE = "Unavailable"; + +export interface InfoExpoConfig { + name?: string; + version?: string; + ios?: { + buildNumber?: string | number; + }; + android?: { + versionCode?: number | string; + }; + extra?: { + EIGHT2FIVE_GIT_SHA?: unknown; + }; +} + +export interface MobileInfoMetadata { + appName: string; + version: string; + nativeBuildLabel: string; + nativeBuildValue: string; + gitSha: string; +} + +export function getMobileInfoMetadata( + config: InfoExpoConfig | null | undefined, + platform: string, + nativeBuildVersion?: string | null, +): MobileInfoMetadata { + const nativeBuildLabel = getNativeBuildLabel(platform); + const nativeBuildValue = + nativeBuildVersion?.trim() || getNativeBuildValue(config, platform); + + return { + appName: EIGHT2FIVE_APP_NAME, + version: config?.version?.trim() || EIGHT2FIVE_APP_VERSION, + nativeBuildLabel, + nativeBuildValue, + gitSha: getShortGitSha(config?.extra?.EIGHT2FIVE_GIT_SHA), + }; +} + +export function getNativeBuildLabel(platform: string): string { + if (platform === "ios") return "iOS build number"; + if (platform === "android") return "Android version code"; + return "Native build"; +} + +export function getShortGitSha(value: unknown): string { + if (typeof value !== "string") return INFO_UNAVAILABLE; + + const normalized = value.trim(); + if (!/^[0-9a-f]{7,40}$/i.test(normalized)) return INFO_UNAVAILABLE; + return normalized.slice(0, 7); +} + +function getNativeBuildValue( + config: InfoExpoConfig | null | undefined, + platform: string, +): string { + const value = + platform === "ios" + ? config?.ios?.buildNumber + : platform === "android" + ? config?.android?.versionCode + : undefined; + + return value == null ? INFO_UNAVAILABLE : String(value); +} diff --git a/apps/mobile/src/features/info/info-screen.tsx b/apps/mobile/src/features/info/info-screen.tsx new file mode 100644 index 00000000..a4c09374 --- /dev/null +++ b/apps/mobile/src/features/info/info-screen.tsx @@ -0,0 +1,175 @@ +import React from "react"; +import Constants from "expo-constants"; +import * as Application from "expo-application"; +import { Linking, Platform } from "react-native"; +import { Card } from "@eight2five/ui/components/card"; +import { Divider } from "@eight2five/ui/components/divider"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Image } from "@eight2five/ui/components/image"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, + useEight2FiveThemeName, +} from "@eight2five/ui/theme"; + +import { + EIGHT2FIVE_GITHUB_URL, + EIGHT2FIVE_LICENSE_URL, + getMobileInfoMetadata, +} from "./info-metadata"; + +const INFO_SPLASH_ASSET_SOURCES = { + ios: { + light: require("../../../assets/splash-icons/mobile-ios-splash-icon-light.png"), + dark: require("../../../assets/splash-icons/mobile-ios-splash-icon-dark.png"), + }, + android: { + light: require("../../../assets/splash-icons/mobile-ios-splash-icon-light.png"), + dark: require("../../../assets/splash-icons/mobile-ios-splash-icon-dark.png"), + }, +} as const; + +export function InfoScreen() { + const theme = useEight2FiveTheme(); + const themeName = useEight2FiveThemeName(); + const metadata = getMobileInfoMetadata( + Constants.expoConfig, + Platform.OS, + Application.nativeBuildVersion, + ); + const platform = Platform.OS === "android" ? "android" : "ios"; + const splashSource = INFO_SPLASH_ASSET_SOURCES[platform][themeName]; + + return ( + + + + + {metadata.appName} + + + + + + + + + + + + + + + + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + const theme = useEight2FiveTheme(); + + return ( + + + {label} + + + {value} + + + ); +} + +function ExternalLink({ + label, + testID, + url, +}: { + label: string; + testID: string; + url: string; +}) { + const theme = useEight2FiveTheme(); + + return ( + void Linking.openURL(url)} + style={{ padding: eight2FiveSpacing.xs }} + > + + {label} + + + ); +} diff --git a/apps/mobile/src/features/info/info-theme-assets.ts b/apps/mobile/src/features/info/info-theme-assets.ts new file mode 100644 index 00000000..a59b9040 --- /dev/null +++ b/apps/mobile/src/features/info/info-theme-assets.ts @@ -0,0 +1,20 @@ +export const INFO_SPLASH_ASSET_PATHS = { + light: { + ios: "./assets/splash-icons/mobile-ios-splash-icon-light.png", + android: "./assets/splash-icons/mobile-ios-splash-icon-light.png", + }, + dark: { + ios: "./assets/splash-icons/mobile-ios-splash-icon-dark.png", + android: "./assets/splash-icons/mobile-ios-splash-icon-dark.png", + }, +} as const; + +export type InfoThemeName = keyof typeof INFO_SPLASH_ASSET_PATHS; +export type InfoSplashPlatform = "ios" | "android"; + +export function getInfoSplashAssetPath( + themeName: InfoThemeName, + platform: InfoSplashPlatform, +): string { + return INFO_SPLASH_ASSET_PATHS[themeName][platform]; +} diff --git a/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts index 2db39ec0..4d2f9bd0 100644 --- a/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts +++ b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts @@ -11,9 +11,23 @@ describe("mobile native tab navigation", () => { { name: "field", label: "Field" }, { name: "drill", label: "Drill" }, { name: "settings", label: "Settings" }, + { name: "info", label: "Info" }, ]); }); + test("uses native info icons and keeps Info available", () => { + const infoTab = MOBILE_TABS.find(({ name }) => name === "info"); + + expect(infoTab).toEqual({ + name: "info", + label: "Info", + icon: { + sf: { default: "info.circle", selected: "info.circle.fill" }, + md: "info", + }, + }); + }); + test("hides the entire tab bar only for focused landscape Field", () => { expect( shouldHideNativeTabBar({ fieldFocused: true, fieldLandscape: true }), diff --git a/apps/mobile/src/navigation/mobile-tabs.ts b/apps/mobile/src/navigation/mobile-tabs.ts index a8500b57..e5c3a4de 100644 --- a/apps/mobile/src/navigation/mobile-tabs.ts +++ b/apps/mobile/src/navigation/mobile-tabs.ts @@ -1,6 +1,6 @@ import type { NativeTabsTriggerIconProps } from "expo-router/unstable-native-tabs"; -export type MobileTabName = "field" | "drill" | "settings"; +export type MobileTabName = "field" | "drill" | "settings" | "info"; export interface MobileTabConfig { name: MobileTabName; @@ -36,6 +36,14 @@ export const MOBILE_TABS = [ md: "settings", }, }, + { + name: "info", + label: "Info", + icon: { + sf: { default: "info.circle", selected: "info.circle.fill" }, + md: "info", + }, + }, ] as const satisfies readonly MobileTabConfig[]; export function shouldHideNativeTabBar({ diff --git a/package-lock.json b/package-lock.json index 18eb3e13..f0d13ba0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,7 +92,7 @@ }, "apps/mobile": { "name": "eight2five-mobile", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", From ca053e4549d3030eab1ebaccd831ff9bbd0bfba5 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 18:39:35 -0500 Subject: [PATCH 072/101] refactor(field): unify drill hud metric modes --- .../field/__tests__/field-hud-state.test.ts | 55 ++++++++ .../__tests__/drill-pill-presentation.test.ts | 47 +++++++ .../drill-pill/animated-value-switch.tsx | 38 ++++++ .../drill-pill/drill-pill-presentation.ts | 44 ++++++ .../src/features/field/field-hud-state.ts | 129 ++++++++++++++++++ 5 files changed, 313 insertions(+) create mode 100644 apps/mobile/src/features/field/__tests__/field-hud-state.test.ts create mode 100644 apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts create mode 100644 apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx create mode 100644 apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts create mode 100644 apps/mobile/src/features/field/field-hud-state.ts diff --git a/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts new file mode 100644 index 00000000..42c0e6a3 --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts @@ -0,0 +1,55 @@ +import type { DrillSet } from "@eight2five/mobile/drill"; + +import { + INITIAL_FIELD_HUD_STATE, + formatMeasureRange, + getDrillSetHudPresentation, + reduceFieldHudState, +} from "../field-hud-state"; + +const set = (overrides: Partial = {}): DrillSet => ({ + id: "set-2", + drillId: "drill-1", + ordinal: 1, + number: 2, + kind: "set", + countsFromPrevious: 16, + measureRange: { start: 3, end: 4 }, + position: { xSteps: 80, ySteps: 28 }, + ...overrides, +}); + +describe("field HUD state", () => { + test("keeps count display and expansion as explicit session state", () => { + const measures = reduceFieldHudState(INITIAL_FIELD_HUD_STATE, { + type: "toggle-count-display", + }); + expect(measures).toEqual({ + countDisplayMode: "measures", + drillPillExpanded: false, + }); + const expanded = reduceFieldHudState(measures, { + type: "toggle-drill-pill", + }); + expect(expanded.drillPillExpanded).toBe(true); + expect( + reduceFieldHudState(expanded, { type: "collapse-drill-pill" }), + ).toEqual({ countDisplayMode: "measures", drillPillExpanded: false }); + }); + + test("formats counts, measures, metrics, terminology, and coordinates centrally", () => { + const presentation = getDrillSetHudPresentation({ + page: set(), + previousPage: set({ id: "set-1", ordinal: 0, number: 1 }), + terminology: "pages", + metricMode: "crossing-counts", + }); + expect(presentation.term).toBe("Page"); + expect(presentation.counts).toBe("16"); + expect(presentation.measures).toBe("3–4"); + expect(presentation.metricLabel).toBe("xCounts"); + expect(presentation.coordinate).not.toBeNull(); + expect(formatMeasureRange(undefined)).toBe("–"); + expect(formatMeasureRange({ start: 5, end: 5 })).toBe("5"); + }); +}); diff --git a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts new file mode 100644 index 00000000..b09716bf --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts @@ -0,0 +1,47 @@ +import { + getCountMetricPresentation, + getTransitionMetricPresentation, +} from "../drill-pill-presentation"; + +describe("drill pill metric modes", () => { + const rows = [ + { + term: "Set" as const, + set: "1", + counts: "0", + measures: "1", + metricLabel: "xCounts" as const, + metric: "–", + coordinate: null, + }, + { + term: "Set" as const, + set: "2", + counts: "16", + measures: "2–3", + metricLabel: "xCounts" as const, + metric: "8", + coordinate: null, + }, + ]; + + test("applies one count mode coherently to every visible row", () => { + expect( + rows.map((row) => getCountMetricPresentation(row, "measures")), + ).toMatchObject([ + { key: "measures", label: "Measures", value: "1", direction: 1 }, + { key: "measures", label: "Measures", value: "2–3", direction: 1 }, + ]); + }); + + test("uses the same animated contract for transition modes", () => { + expect( + rows.map((row) => + getTransitionMetricPresentation(row, "crossing-counts"), + ), + ).toMatchObject([ + { key: "crossing-counts", label: "xCounts", direction: -1 }, + { key: "crossing-counts", label: "xCounts", direction: -1 }, + ]); + }); +}); diff --git a/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx new file mode 100644 index 00000000..b2ea7763 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import Animated, { + FadeInLeft, + FadeInRight, + FadeOutLeft, + FadeOutRight, + ReduceMotion, +} from "react-native-reanimated"; + +export function AnimatedValueSwitch({ + displayKey, + direction, + children, + style, + testID, +}: { + readonly displayKey: string; + readonly direction: -1 | 1; + readonly children: React.ReactNode; + readonly style?: StyleProp; + readonly testID?: string; +}) { + const entering = (direction > 0 ? FadeInRight : FadeInLeft) + .duration(180) + .reduceMotion(ReduceMotion.System); + const exiting = (direction > 0 ? FadeOutLeft : FadeOutRight) + .duration(180) + .reduceMotion(ReduceMotion.System); + + return ( + + + {children} + + + ); +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts new file mode 100644 index 00000000..ad6c0ded --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts @@ -0,0 +1,44 @@ +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import type { + CountDisplayMode, + DrillSetHudPresentation, +} from "../field-hud-state"; + +export interface AnimatedMetricPresentation { + readonly key: string; + readonly direction: -1 | 1; + readonly label: string; + readonly value: string; +} + +export function getCountMetricPresentation( + presentation: DrillSetHudPresentation, + mode: CountDisplayMode, +): AnimatedMetricPresentation { + return mode === "counts" + ? { + key: mode, + direction: -1, + label: "Counts", + value: presentation.counts, + } + : { + key: mode, + direction: 1, + label: "Measures", + value: presentation.measures, + }; +} + +export function getTransitionMetricPresentation( + presentation: DrillSetHudPresentation, + mode: TransitionMetricMode, +): AnimatedMetricPresentation { + return { + key: mode, + direction: mode === "step-size" ? 1 : -1, + label: presentation.metricLabel, + value: presentation.metric, + }; +} diff --git a/apps/mobile/src/features/field/field-hud-state.ts b/apps/mobile/src/features/field/field-hud-state.ts new file mode 100644 index 00000000..a6a1dad2 --- /dev/null +++ b/apps/mobile/src/features/field/field-hud-state.ts @@ -0,0 +1,129 @@ +import type { FieldPresetId } from "@eight2five/drill-schema"; +import { + drillGridPointToMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, +} from "@eight2five/mobile/field"; +import { + formatSetName, + getDrillTerms, + type DrillSet, + type DrillTerminology, +} from "@eight2five/mobile/drill"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import { getTransitionPresentation } from "../drill/transition-presentation"; + +export type CountDisplayMode = "counts" | "measures"; + +export interface FieldHudState { + readonly countDisplayMode: CountDisplayMode; + readonly drillPillExpanded: boolean; +} + +export type FieldHudAction = + | { readonly type: "toggle-count-display" } + | { readonly type: "toggle-drill-pill" } + | { readonly type: "collapse-drill-pill" }; + +export const INITIAL_FIELD_HUD_STATE: FieldHudState = Object.freeze({ + countDisplayMode: "counts", + drillPillExpanded: false, +}); + +export function reduceFieldHudState( + state: FieldHudState, + action: FieldHudAction, +): FieldHudState { + switch (action.type) { + case "toggle-count-display": + return { + ...state, + countDisplayMode: + state.countDisplayMode === "counts" ? "measures" : "counts", + }; + case "toggle-drill-pill": + return { ...state, drillPillExpanded: !state.drillPillExpanded }; + case "collapse-drill-pill": + return state.drillPillExpanded + ? { ...state, drillPillExpanded: false } + : state; + } +} + +export interface CoordinateLines { + readonly side: string; + readonly frontBack: string; +} + +export interface DrillSetHudPresentation { + readonly term: "Page" | "Set"; + readonly set: string; + readonly counts: string; + readonly measures: string; + readonly metricLabel: "Step Size" | "xCounts"; + readonly metric: string; + readonly coordinate: CoordinateLines | null; + readonly emptyMessage?: string; +} + +export function formatDrillCoordinateLines( + position: DrillSet["position"], + fieldPreset: FieldPresetId = "football-nfhs", +): CoordinateLines { + const coordinate = drillGridPointToMarchingCoordinate(position, fieldPreset); + return { + side: formatMarchingSide(coordinate.side), + frontBack: formatMarchingFrontBack(coordinate.frontBack, fieldPreset), + }; +} + +export function getDrillSetHudPresentation({ + page, + previousPage, + metricMode, + fieldPreset = "football-nfhs", + terminology, +}: { + readonly page?: DrillSet; + readonly previousPage?: DrillSet; + readonly metricMode: TransitionMetricMode; + readonly fieldPreset?: FieldPresetId; + readonly terminology: DrillTerminology; +}): DrillSetHudPresentation { + const terms = getDrillTerms(terminology); + const metricLabel = metricMode === "step-size" ? "Step Size" : "xCounts"; + if (!page) { + return { + term: terms.singular, + set: "–", + counts: "–", + measures: "–", + metricLabel, + metric: "–", + coordinate: null, + emptyMessage: `No drill ${terms.lowercaseSingular} selected`, + }; + } + + const transition = getTransitionPresentation(previousPage, page); + return { + term: terms.singular, + set: formatSetName(page), + counts: String(page.countsFromPrevious), + measures: formatMeasureRange(page.measureRange), + metricLabel, + metric: + metricMode === "step-size" + ? transition.stepSize + : transition.crossingCounts, + coordinate: formatDrillCoordinateLines(page.position, fieldPreset), + }; +} + +export function formatMeasureRange(range: DrillSet["measureRange"]): string { + if (!range) return "–"; + return range.start === range.end + ? String(range.start) + : `${range.start}–${range.end}`; +} From 2e115d76a1f8fd75b8ff1f3298e21ba3b1c5a61c Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 18:39:45 -0500 Subject: [PATCH 073/101] feat(field): add expandable drill pill --- .../__tests__/drill-pill-layout.test.ts | 20 ++ .../field/drill-pill/drill-pill-layout.ts | 31 +++ .../features/field/drill-pill/drill-pill.tsx | 143 ++++++++++++ .../field/drill-pill/drill-set-list.tsx | 127 +++++++++++ .../drill-pill/drill-set-metric-grid.tsx | 209 ++++++++++++++++++ 5 files changed, 530 insertions(+) create mode 100644 apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts create mode 100644 apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts create mode 100644 apps/mobile/src/features/field/drill-pill/drill-pill.tsx create mode 100644 apps/mobile/src/features/field/drill-pill/drill-set-list.tsx create mode 100644 apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx diff --git a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts new file mode 100644 index 00000000..e858aae9 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts @@ -0,0 +1,20 @@ +import { getDrillPillColumnMetrics } from "../drill-pill-layout"; + +describe("drill pill layout", () => { + test.each([ + [360, false], + [390, false], + [600, true], + ])("prioritizes a usable coordinate column at %ipx", (width, landscape) => { + const metrics = getDrillPillColumnMetrics(width, landscape); + const used = + metrics.horizontalPadding * 2 + + metrics.gap * 3 + + metrics.setWidth + + metrics.countWidth + + metrics.metricWidth + + metrics.coordinateWidth; + expect(used).toBeCloseTo(width); + expect(metrics.coordinateWidth).toBeGreaterThanOrEqual(100); + }); +}); diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts b/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts new file mode 100644 index 00000000..befff644 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts @@ -0,0 +1,31 @@ +export interface DrillPillColumnMetrics { + readonly horizontalPadding: number; + readonly gap: number; + readonly setWidth: number; + readonly countWidth: number; + readonly metricWidth: number; + readonly coordinateWidth: number; +} + +export function getDrillPillColumnMetrics( + width: number, + landscape: boolean, +): DrillPillColumnMetrics { + const horizontalPadding = width < 380 || landscape ? 10 : 14; + const gap = width < 380 || landscape ? 6 : 10; + const contentWidth = Math.max(0, width - horizontalPadding * 2 - gap * 3); + const setWidth = Math.min(64, Math.max(48, contentWidth * 0.16)); + const countWidth = Math.min(82, Math.max(62, contentWidth * 0.2)); + const metricWidth = Math.min(96, Math.max(72, contentWidth * 0.23)); + return { + horizontalPadding, + gap, + setWidth, + countWidth, + metricWidth, + coordinateWidth: Math.max( + 0, + contentWidth - setWidth - countWidth - metricWidth, + ), + }; +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill.tsx b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx new file mode 100644 index 00000000..dc4038e2 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import Animated, { + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import type { FieldPresetId } from "@eight2five/drill-schema"; +import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; +import { Divider } from "@eight2five/ui/components/divider"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { + getDrillSetHudPresentation, + type CountDisplayMode, +} from "../field-hud-state"; +import { getDrillPillColumnMetrics } from "./drill-pill-layout"; +import { DRILL_SET_ROW_HEIGHT, DrillSetList } from "./drill-set-list"; +import { DrillSetMetricGrid } from "./drill-set-metric-grid"; + +export function DrillPill({ + width, + landscape, + listMaxHeight, + pages, + selectedIndex, + terminology, + countDisplayMode, + metricMode, + fieldPreset, + expanded, + controlsDisabled, + error, + onToggleCounts, + onToggleMetric, + onToggleExpanded, + onSelectIndex, +}: { + readonly width: number; + readonly landscape: boolean; + readonly listMaxHeight: number; + readonly pages: readonly DrillSet[]; + readonly selectedIndex: number; + readonly terminology: DrillTerminology; + readonly countDisplayMode: CountDisplayMode; + readonly metricMode: TransitionMetricMode; + readonly fieldPreset: FieldPresetId; + readonly expanded: boolean; + readonly controlsDisabled: boolean; + readonly error?: Error; + readonly onToggleCounts: () => void; + readonly onToggleMetric: () => void; + readonly onToggleExpanded: () => void; + readonly onSelectIndex: (index: number) => void; +}) { + const theme = useEight2FiveTheme(); + const columns = React.useMemo( + () => getDrillPillColumnMetrics(width, landscape), + [landscape, width], + ); + const current = selectedIndex >= 0 ? pages[selectedIndex] : undefined; + const presentation = getDrillSetHudPresentation({ + page: current, + previousPage: selectedIndex > 0 ? pages[selectedIndex - 1] : undefined, + metricMode, + fieldPreset, + terminology, + }); + const availableListHeight = Math.min( + listMaxHeight, + Math.max(0, pages.length * DRILL_SET_ROW_HEIGHT + 1), + ); + const animatedHeight = useSharedValue(expanded ? availableListHeight : 0); + React.useEffect(() => { + animatedHeight.value = withTiming(expanded ? availableListHeight : 0, { + duration: 220, + reduceMotion: ReduceMotion.System, + }); + }, [animatedHeight, availableListHeight, expanded]); + const listStyle = useAnimatedStyle(() => ({ height: animatedHeight.value })); + + return ( + + + {error ? ( + + {error.message} + + ) : null} + + + + + + ); +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx new file mode 100644 index 00000000..37df3bea --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { FlatList, type ListRenderItemInfo } from "react-native"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; +import type { FieldPresetId } from "@eight2five/drill-schema"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { + getDrillSetHudPresentation, + type CountDisplayMode, +} from "../field-hud-state"; +import { DrillSetMetricGrid } from "./drill-set-metric-grid"; +import type { DrillPillColumnMetrics } from "./drill-pill-layout"; + +export const DRILL_SET_ROW_HEIGHT = 84; + +export function DrillSetList({ + pages, + selectedIndex, + columns, + countDisplayMode, + metricMode, + terminology, + fieldPreset, + expanded, + onSelectIndex, +}: { + readonly pages: readonly DrillSet[]; + readonly selectedIndex: number; + readonly columns: DrillPillColumnMetrics; + readonly countDisplayMode: CountDisplayMode; + readonly metricMode: TransitionMetricMode; + readonly terminology: DrillTerminology; + readonly fieldPreset: FieldPresetId; + readonly expanded: boolean; + readonly onSelectIndex: (index: number) => void; +}) { + const theme = useEight2FiveTheme(); + const listRef = React.useRef>(null); + + React.useEffect(() => { + if (!expanded || selectedIndex < 0 || pages.length === 0) return; + const frame = requestAnimationFrame(() => { + listRef.current?.scrollToIndex({ + index: selectedIndex, + animated: false, + viewPosition: 0.5, + }); + }); + return () => cancelAnimationFrame(frame); + }, [expanded, pages.length, selectedIndex]); + + const renderItem = React.useCallback( + ({ item, index }: ListRenderItemInfo) => { + const selected = index === selectedIndex; + const presentation = getDrillSetHudPresentation({ + page: item, + previousPage: index > 0 ? pages[index - 1] : undefined, + metricMode, + fieldPreset, + terminology, + }); + return ( + onSelectIndex(index)} + style={{ + height: DRILL_SET_ROW_HEIGHT, + justifyContent: "center", + backgroundColor: selected ? theme.accent : "transparent", + }} + testID={`drill-set-row-${index}`} + > + + + ); + }, + [ + columns, + countDisplayMode, + fieldPreset, + metricMode, + onSelectIndex, + pages, + selectedIndex, + terminology, + theme.accent, + ], + ); + + return ( + page.id} + getItemLayout={(_data, index) => ({ + index, + length: DRILL_SET_ROW_HEIGHT, + offset: DRILL_SET_ROW_HEIGHT * index, + })} + extraData={`${countDisplayMode}:${metricMode}:${selectedIndex}`} + style={{ flex: 1 }} + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={7} + nestedScrollEnabled + keyboardShouldPersistTaps="handled" + onScrollToIndexFailed={({ index }) => { + listRef.current?.scrollToOffset({ + offset: Math.max(0, index * DRILL_SET_ROW_HEIGHT), + animated: false, + }); + }} + testID="drill-set-list" + /> + ); +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx new file mode 100644 index 00000000..a04dcfe2 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx @@ -0,0 +1,209 @@ +import React from "react"; +import { ChevronDown, ChevronUp } from "lucide-react-native"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import type { + CountDisplayMode, + DrillSetHudPresentation, +} from "../field-hud-state"; +import { AnimatedValueSwitch } from "./animated-value-switch"; +import type { DrillPillColumnMetrics } from "./drill-pill-layout"; +import { + getCountMetricPresentation, + getTransitionMetricPresentation, +} from "./drill-pill-presentation"; + +export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ + presentation, + columns, + countDisplayMode, + metricMode, + selected = false, + header = false, + expanded = false, + onToggleCounts, + onToggleMetric, + onToggleExpanded, +}: { + readonly presentation: DrillSetHudPresentation; + readonly columns: DrillPillColumnMetrics; + readonly countDisplayMode: CountDisplayMode; + readonly metricMode: TransitionMetricMode; + readonly selected?: boolean; + readonly header?: boolean; + readonly expanded?: boolean; + readonly onToggleCounts?: () => void; + readonly onToggleMetric?: () => void; + readonly onToggleExpanded?: () => void; +}) { + const theme = useEight2FiveTheme(); + const labelColor = selected ? theme.raw.white : theme.textMuted; + const valueColor = selected ? theme.raw.white : theme.text; + const count = getCountMetricPresentation(presentation, countDisplayMode); + const metric = getTransitionMetricPresentation(presentation, metricMode); + + return ( + + + + + + + + + + + + + + + + + Marching Coordinate + + + {presentation.coordinate + ? `${presentation.coordinate.side}\n${presentation.coordinate.frontBack}` + : "–"} + + + {onToggleExpanded ? ( + + ) : null} + + + + ); +}); + +function MetricCell({ + width, + label, + value, + labelColor, + valueColor, +}: { + readonly width?: number; + readonly label: string; + readonly value: string; + readonly labelColor: string; + readonly valueColor: string; +}) { + return ( + + + {label} + + + {value} + + + ); +} From 69b2f482e8d27614478a5a7d66e6c6d7785eb0a7 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 18:39:53 -0500 Subject: [PATCH 074/101] feat(field): redesign set control dial --- .../__tests__/page-dial-math.test.ts | 85 ++++- .../field/page-dial/page-dial-canvas.tsx | 36 +- .../field/page-dial/page-dial-controls.tsx | 108 ++++-- .../field/page-dial/page-dial-gesture.ts | 108 ++++-- .../field/page-dial/page-dial-layout.ts | 42 ++- .../field/page-dial/page-dial-math.ts | 325 +++++++++++++++++- .../features/field/page-dial/page-dial.tsx | 115 +++++-- .../src/field/render/page-dial-canvas.tsx | 185 ++++++++-- 8 files changed, 863 insertions(+), 141 deletions(-) diff --git a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts index d6bcd9a2..1b1ee2f9 100644 --- a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts +++ b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts @@ -1,4 +1,13 @@ import { + getPageDialCardinalPoints, + getPageDialDividerSegments, + getPageDialRingHitRegion, + pageDialAngleIsInValidArc, + pageDialIndexForProgress, + pageDialPointForProgress, + pageDialPointIsInRingHitRegion, + pageDialProgressForAngle, + pageDialProgressForPoint, PAGE_DIAL_START_ANGLE_DEGREES, PAGE_DIAL_USABLE_ARC_DEGREES, normalizePageIndex, @@ -31,6 +40,51 @@ describe("page dial math", () => { expect(pageDialIndexForAngle(radians(90), 5)).toBe(2); }); + test("keeps cardinal angles on one continuous valid arc", () => { + expect(pageDialProgressForAngle(radians(0))).toBeCloseTo(85 / 350); + expect(pageDialProgressForAngle(radians(90))).toBeCloseTo(0.5); + expect(pageDialProgressForAngle(radians(180))).toBeCloseTo(265 / 350); + expect(pageDialAngleIsInValidArc(radians(-85))).toBe(true); + expect(pageDialAngleIsInValidArc(radians(265))).toBe(true); + }); + + test("uses a deterministic top dead zone without wrapping", () => { + expect(pageDialProgressForAngle(radians(-90))).toBe(0); + expect(pageDialProgressForAngle(radians(-89.9))).toBe(0); + expect(pageDialProgressForAngle(radians(-90.1))).toBe(1); + expect(pageDialProgressForAngle(radians(-85.1))).toBe(0); + expect(pageDialProgressForAngle(radians(265.1))).toBe(1); + expect(pageDialIndexForProgress(0.5, 5)).toBe(2); + }); + + test("accepts an enlarged radial ring hit region but not its center", () => { + const diameter = 200; + const center = diameter / 2; + const region = getPageDialRingHitRegion(diameter); + expect( + pageDialPointIsInRingHitRegion( + center + region.innerRadius - 0.1, + center, + diameter, + ), + ).toBe(false); + expect( + pageDialPointIsInRingHitRegion( + center + (region.innerRadius + region.outerRadius) / 2, + center, + diameter, + ), + ).toBe(true); + expect( + pageDialPointIsInRingHitRegion( + center + region.outerRadius + 0.1, + center, + diameter, + ), + ).toBe(false); + expect(pageDialProgressForPoint(center, center, diameter)).toBe(0); + }); + test("disables unavailable first and last actions", () => { expect(getPageDialControlState(0, 4)).toEqual({ previousDisabled: true, @@ -48,12 +102,14 @@ describe("page dial math", () => { test("uses supplied proportions and terminology-aware accessibility", () => { const proportions = getPageDialProportions(100); - expect(proportions.ringThickness).toBeCloseTo(7); + expect(proportions.ringThickness).toBeCloseTo(7.5); expect(proportions.innerDiskDiameter).toBeCloseTo(86); expect(proportions.centerDiskDiameter).toBeCloseTo(30); - expect(proportions.centerBorderWidth).toBeCloseTo(1.8); - expect(proportions.knobDiameter).toBeCloseTo(13); + expect(proportions.centerBorderWidth).toBe(0); + expect(proportions.knobDiameter).toBeCloseTo(16); expect(proportions.controlCenterOffset).toBeCloseTo(29); + expect(proportions.controlButtonSize).toBeGreaterThanOrEqual(44); + expect(proportions.canvasOverscan).toBeGreaterThan(proportions.knobRadius); expect( getPageDialAccessibilityLabel({ selectedIndex: 21, @@ -63,4 +119,27 @@ describe("page dial math", () => { }), ).toBe("Set selector, set 22 of 38"); }); + + test("places controls at four equal cardinal points and keeps the knob in overscan", () => { + const diameter = 200; + const points = getPageDialCardinalPoints(diameter); + const center = diameter / 2; + const distances = [ + Math.hypot(points.top.x - center, points.top.y - center), + Math.hypot(points.right.x - center, points.right.y - center), + Math.hypot(points.bottom.x - center, points.bottom.y - center), + Math.hypot(points.left.x - center, points.left.y - center), + ]; + distances.forEach((distance) => expect(distance).toBeCloseTo(distances[0])); + + const proportions = getPageDialProportions(diameter); + const knob = pageDialPointForProgress(0, diameter, proportions.ringRadius); + expect(knob.x - proportions.knobRadius).toBeGreaterThanOrEqual( + -proportions.canvasOverscan, + ); + expect(knob.y - proportions.knobRadius).toBeGreaterThanOrEqual( + -proportions.canvasOverscan, + ); + expect(getPageDialDividerSegments(diameter)).toHaveLength(4); + }); }); diff --git a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx index c79f71d7..aba06252 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx @@ -1,27 +1,48 @@ import { FieldPageDialCanvas } from "@eight2five/mobile/field/render"; +import { useMemo } from "react"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import { - normalizePageIndex, + getPageDialDividerSegments, PAGE_DIAL_START_ANGLE_DEGREES, PAGE_DIAL_USABLE_ARC_DEGREES, } from "./page-dial-math"; +import { getPageDialProportions } from "./page-dial-layout"; export function PageDialCanvas({ diameter, pageCount, - provisionalIndex, + provisionalProgress, activeColor, trackColor, + innerColor, + backgroundColor, + foregroundColor, + dividerColor, }: { readonly diameter: number; readonly pageCount: number; - readonly provisionalIndex: SharedValue; + readonly provisionalProgress: SharedValue; readonly activeColor: string; readonly trackColor: string; + readonly innerColor?: string; + readonly backgroundColor?: string; + readonly foregroundColor?: string; + readonly dividerColor?: string; }) { - const progress = useDerivedValue(() => - normalizePageIndex(provisionalIndex.value, pageCount), + const progress = useDerivedValue(() => { + if (pageCount <= 0 || !Number.isFinite(provisionalProgress.value)) return 0; + return Math.min(1, Math.max(0, provisionalProgress.value)); + }); + const proportions = getPageDialProportions(diameter); + const dividerSegments = useMemo( + () => + getPageDialDividerSegments( + diameter, + proportions.innerDiskDiameter, + proportions.centerDiskDiameter, + ), + [diameter, proportions.centerDiskDiameter, proportions.innerDiskDiameter], ); return ( ); } diff --git a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx index 0d5441f7..1572345e 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx @@ -2,7 +2,7 @@ import { Center } from "@eight2five/ui/components/center"; import { Icon } from "@eight2five/ui/components/icon"; import { Pressable } from "@eight2five/ui/components/pressable"; import { Text } from "@eight2five/ui/components/text"; -import { Minus, Plus } from "lucide-react-native"; +import { CircleUserRound, Folder, Minus, Plus } from "lucide-react-native"; import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill"; import { @@ -10,6 +10,10 @@ import { getPageDialControlState, getPageDialProportions, } from "./page-dial-layout"; +import { + getPageDialCardinalPoints, + getPageDialControlSize, +} from "./page-dial-math"; export function PageDialControls({ diameter, @@ -19,6 +23,9 @@ export function PageDialControls({ terminology, onPrevious, onNext, + onSelectDrill, + onSelectPerformer, + foregroundColor = "#FFFFFF", }: { readonly diameter: number; readonly selectedIndex: number; @@ -27,37 +34,62 @@ export function PageDialControls({ readonly terminology: DrillTerminology; readonly onPrevious: () => void; readonly onNext: () => void; + readonly onSelectDrill?: () => void; + readonly onSelectPerformer?: () => void; + readonly foregroundColor?: string; }) { const terms = getDrillTerms(terminology); const proportions = getPageDialProportions(diameter); const state = getPageDialControlState(selectedIndex, pageCount); - const buttonSize = Math.max(48, diameter * 0.31); + const buttonSize = getPageDialControlSize(diameter); const center = diameter / 2; - const previousCenter = center - proportions.controlCenterOffset; - const nextCenter = center + proportions.controlCenterOffset; + const controlCenters = getPageDialCardinalPoints( + diameter, + proportions.controlCenterOffset, + ); const centerDiameter = proportions.centerDiskDiameter; + const buttonStyle = (x: number, y: number, disabled = false) => ({ + position: "absolute" as const, + left: x - buttonSize / 2, + top: y - buttonSize / 2, + width: buttonSize, + height: buttonSize, + alignItems: "center" as const, + justifyContent: "center" as const, + opacity: disabled ? 0.34 : 1, + }); return ( <> + + + - +

- {terms.singular} + {terms.plural} = 0 ? (selectedLabel ?? selectedIndex + 1) : "–"}
+ + + - + ); diff --git a/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts index bb75b383..ce686528 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts +++ b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts @@ -1,10 +1,22 @@ import React from "react"; import * as Haptics from "expo-haptics"; import { Gesture } from "react-native-gesture-handler"; -import { useSharedValue, type SharedValue } from "react-native-reanimated"; +import { + cancelAnimation, + useSharedValue, + withSpring, + withTiming, + type SharedValue, +} from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; -import { pageDialIndexForPoint } from "./page-dial-math"; +import { + normalizePageIndex, + pageDialIndexForProgress, + pageDialPointIsInControlHitTarget, + pageDialProgressForPoint, + pageDialPointIsInRingHitRegion, +} from "./page-dial-math"; function setSharedValue(sharedValue: SharedValue, value: T): void { "worklet"; @@ -18,24 +30,30 @@ export function triggerPageDialHaptic(): void { export function usePageDialGesture({ diameter, pageCount, - provisionalIndex, + selectedIndex = 0, + provisionalProgress, onCommitIndex, }: { readonly diameter: number; readonly pageCount: number; - readonly provisionalIndex: SharedValue; + readonly selectedIndex?: number; + readonly provisionalProgress: SharedValue; readonly onCommitIndex: (index: number) => void; }) { const ringActive = useSharedValue(false); + const gestureStartProgress = useSharedValue(0); const gestureStartIndex = useSharedValue(0); + const previewIndex = useSharedValue(0); - const updateFromPoint = (x: number, y: number) => { + const updateFromPoint = (x: number, y: number, shouldHaptic = true) => { "worklet"; if (!ringActive.value || pageCount <= 0) return; - const nextIndex = pageDialIndexForPoint(x, y, diameter, pageCount); - if (nextIndex === provisionalIndex.value) return; - setSharedValue(provisionalIndex, nextIndex); - scheduleOnRN(triggerPageDialHaptic); + const nextProgress = pageDialProgressForPoint(x, y, diameter); + const nextIndex = pageDialIndexForProgress(nextProgress, pageCount); + setSharedValue(provisionalProgress, nextProgress); + if (nextIndex === previewIndex.value) return; + setSharedValue(previewIndex, nextIndex); + if (shouldHaptic) scheduleOnRN(triggerPageDialHaptic); }; const commitIndex = React.useCallback( @@ -43,31 +61,77 @@ export function usePageDialGesture({ [onCommitIndex], ); + const settleProgress = (index: number) => { + "worklet"; + const targetProgress = normalizePageIndex(index, pageCount); + const shouldCommit = index !== gestureStartIndex.value; + setSharedValue( + provisionalProgress, + withSpring( + targetProgress, + { + damping: 18, + stiffness: 210, + mass: 0.7, + }, + (finished) => { + "worklet"; + if (finished && shouldCommit) { + scheduleOnRN(commitIndex, index); + } + }, + ), + ); + }; + return Gesture.Pan() .withTestId("page-dial-ring-gesture") + .manualActivation(true) + .onTouchesDown((event, manager) => { + const touch = event.allTouches[0]; + if ( + touch && + pageCount > 0 && + pageDialPointIsInRingHitRegion(touch.x, touch.y, diameter) && + !pageDialPointIsInControlHitTarget(touch.x, touch.y, diameter) + ) { + manager.activate(); + } else { + manager.fail(); + } + }) .minDistance(1) .onBegin((event) => { - const center = diameter / 2; - const radialDistance = Math.hypot(event.x - center, event.y - center); const touchesRing = - radialDistance >= diameter * 0.455 && radialDistance <= diameter * 0.57; + pageDialPointIsInRingHitRegion(event.x, event.y, diameter) && + !pageDialPointIsInControlHitTarget(event.x, event.y, diameter); setSharedValue(ringActive, touchesRing && pageCount > 0); - setSharedValue(gestureStartIndex, provisionalIndex.value); - updateFromPoint(event.x, event.y); + if (!touchesRing || pageCount <= 0) return; + cancelAnimation(provisionalProgress); + setSharedValue(gestureStartProgress, provisionalProgress.value); + setSharedValue( + gestureStartIndex, + selectedIndex >= 0 ? selectedIndex : -1, + ); + setSharedValue(previewIndex, gestureStartIndex.value); + updateFromPoint(event.x, event.y, false); }) .onUpdate((event) => updateFromPoint(event.x, event.y)) .onEnd((_event, success) => { - if ( - success && - ringActive.value && - provisionalIndex.value !== gestureStartIndex.value - ) { - scheduleOnRN(commitIndex, provisionalIndex.value); - } + if (!success || !ringActive.value || pageCount <= 0) return; + const snappedIndex = pageDialIndexForProgress( + provisionalProgress.value, + pageCount, + ); + settleProgress(snappedIndex); }) .onFinalize((_event, success) => { if (!success && ringActive.value) { - setSharedValue(provisionalIndex, gestureStartIndex.value); + cancelAnimation(provisionalProgress); + setSharedValue( + provisionalProgress, + withTiming(gestureStartProgress.value, { duration: 140 }), + ); } setSharedValue(ringActive, false); }); diff --git a/apps/mobile/src/features/field/page-dial/page-dial-layout.ts b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts index 60d1c497..0b0b1fec 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-layout.ts +++ b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts @@ -1,22 +1,52 @@ import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill"; +import { + getPageDialCanvasOverscan, + getPageDialControlCenterOffset, + getPageDialControlSize, + getPageDialRingHitRegion, + getPageDialRingRadius, + PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO, + PAGE_DIAL_INNER_DISK_DIAMETER_RATIO, + PAGE_DIAL_KNOB_DIAMETER_RATIO, + PAGE_DIAL_RING_THICKNESS_RATIO, +} from "./page-dial-math"; + export interface PageDialProportions { readonly ringThickness: number; + readonly ringRadius: number; readonly innerDiskDiameter: number; readonly centerDiskDiameter: number; + /** Kept for callers that used the original proportions API. No outline is rendered. */ readonly centerBorderWidth: number; readonly knobDiameter: number; + readonly knobRadius: number; readonly controlCenterOffset: number; + readonly controlButtonSize: number; + readonly ringHitInnerRadius: number; + readonly ringHitOuterRadius: number; + readonly canvasOverscan: number; } export function getPageDialProportions(diameter: number): PageDialProportions { + const ringThickness = diameter * PAGE_DIAL_RING_THICKNESS_RATIO; + const innerDiskDiameter = diameter * PAGE_DIAL_INNER_DISK_DIAMETER_RATIO; + const centerDiskDiameter = diameter * PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO; + const knobDiameter = diameter * PAGE_DIAL_KNOB_DIAMETER_RATIO; + const ringHitRegion = getPageDialRingHitRegion(diameter); return { - ringThickness: diameter * 0.07, - innerDiskDiameter: diameter * 0.86, - centerDiskDiameter: diameter * 0.3, - centerBorderWidth: diameter * 0.018, - knobDiameter: diameter * 0.13, - controlCenterOffset: diameter * 0.29, + ringThickness, + ringRadius: getPageDialRingRadius(diameter, ringThickness), + innerDiskDiameter, + centerDiskDiameter, + centerBorderWidth: 0, + knobDiameter, + knobRadius: knobDiameter / 2, + controlCenterOffset: getPageDialControlCenterOffset(diameter), + controlButtonSize: getPageDialControlSize(diameter), + ringHitInnerRadius: ringHitRegion.innerRadius, + ringHitOuterRadius: ringHitRegion.outerRadius, + canvasOverscan: getPageDialCanvasOverscan(diameter), }; } diff --git a/apps/mobile/src/features/field/page-dial/page-dial-math.ts b/apps/mobile/src/features/field/page-dial/page-dial-math.ts index 2834a790..2391ee34 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-math.ts +++ b/apps/mobile/src/features/field/page-dial/page-dial-math.ts @@ -1,52 +1,164 @@ +/** + * Geometry shared by the React Native controls, the gesture worklet, and the + * Skia renderer. Angles use the React Native coordinate system: zero points + * right and positive values rotate clockwise because y grows downwards. + */ + export const PAGE_DIAL_DEAD_ZONE_DEGREES = 10; export const PAGE_DIAL_USABLE_ARC_DEGREES = 360 - PAGE_DIAL_DEAD_ZONE_DEGREES; export const PAGE_DIAL_START_ANGLE_DEGREES = -90 + PAGE_DIAL_DEAD_ZONE_DEGREES / 2; +export const PAGE_DIAL_END_ANGLE_DEGREES = + PAGE_DIAL_START_ANGLE_DEGREES + PAGE_DIAL_USABLE_ARC_DEGREES; + +export const PAGE_DIAL_RING_THICKNESS_RATIO = 0.075; +export const PAGE_DIAL_INNER_DISK_DIAMETER_RATIO = 0.86; +export const PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO = 0.3; +export const PAGE_DIAL_KNOB_DIAMETER_RATIO = 0.16; +export const PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO = 0.29; +export const PAGE_DIAL_MIN_CONTROL_SIZE = 44; + +// The visual ring is about 0.46D from the center. Keeping this hit region +// wider makes the dial usable with a thumb without making the center button +// part of the gesture surface. +export const PAGE_DIAL_RING_HIT_INNER_RADIUS_RATIO = 0.36; +export const PAGE_DIAL_RING_HIT_OUTER_RADIUS_RATIO = 0.6; + +// The knob and its shadow extend beyond the visual ring at the two endpoints. +// The canvas is rendered with this much overscan so neither is clipped. +export const PAGE_DIAL_CANVAS_OVERSCAN_RATIO = 0.09; const FULL_TURN_RADIANS = Math.PI * 2; +const DEGREES_TO_RADIANS = Math.PI / 180; + +export interface PageDialPoint { + readonly x: number; + readonly y: number; +} + +export interface PageDialLineSegment { + readonly start: PageDialPoint; + readonly end: PageDialPoint; +} + +export interface PageDialRingHitRegion { + readonly innerRadius: number; + readonly outerRadius: number; +} + +export interface PageDialCardinalPoints { + readonly top: PageDialPoint; + readonly right: PageDialPoint; + readonly bottom: PageDialPoint; + readonly left: PageDialPoint; +} + +function clamp(value: number, minimum: number, maximum: number): number { + "worklet"; + return Math.min(maximum, Math.max(minimum, value)); +} + +function normalizeRadians(angleRadians: number): number { + "worklet"; + const normalized = angleRadians % FULL_TURN_RADIANS; + return normalized < 0 ? normalized + FULL_TURN_RADIANS : normalized; +} export function normalizePageIndex(index: number, pageCount: number): number { "worklet"; - if (pageCount <= 1) return 0; - return Math.min(1, Math.max(0, index / (pageCount - 1))); + if ( + pageCount <= 1 || + !Number.isFinite(pageCount) || + !Number.isFinite(index) + ) { + return 0; + } + return clamp(index / (pageCount - 1), 0, 1); +} + +export const normalizePageProgress = normalizePageIndex; + +export function pageDialAngleForProgress(progress: number): number { + "worklet"; + const safeProgress = Number.isFinite(progress) ? progress : 0; + return ( + (PAGE_DIAL_START_ANGLE_DEGREES + + clamp(safeProgress, 0, 1) * PAGE_DIAL_USABLE_ARC_DEGREES) * + DEGREES_TO_RADIANS + ); } export function pageDialAngleForIndex( index: number, pageCount: number, ): number { + "worklet"; + return pageDialAngleForProgress(normalizePageIndex(index, pageCount)); +} + +export function pageDialRelativeAngle(angleRadians: number): number { + "worklet"; + const start = PAGE_DIAL_START_ANGLE_DEGREES * DEGREES_TO_RADIANS; + return normalizeRadians(angleRadians - start); +} + +export function pageDialAngleIsInValidArc(angleRadians: number): boolean { "worklet"; return ( - ((PAGE_DIAL_START_ANGLE_DEGREES + - normalizePageIndex(index, pageCount) * PAGE_DIAL_USABLE_ARC_DEGREES) * - Math.PI) / - 180 + pageDialRelativeAngle(angleRadians) <= + PAGE_DIAL_USABLE_ARC_DEGREES * DEGREES_TO_RADIANS ); } +export const isPageDialAngleInValidArc = pageDialAngleIsInValidArc; + export function pageDialProgressForAngle(angleRadians: number): number { "worklet"; - const start = (PAGE_DIAL_START_ANGLE_DEGREES * Math.PI) / 180; - const usableArc = (PAGE_DIAL_USABLE_ARC_DEGREES * Math.PI) / 180; - const rawRelative = (angleRadians - start) % FULL_TURN_RADIANS; - const relative = - rawRelative < 0 ? rawRelative + FULL_TURN_RADIANS : rawRelative; - if (relative <= usableArc) return relative / usableArc; + if (!Number.isFinite(angleRadians)) return 0; - // Touches in the top dead zone clamp to the nearest endpoint. This avoids - // wrapping directly from the first page to the last across ±π. - const distanceFromEnd = relative - usableArc; + const usableArcRadians = PAGE_DIAL_USABLE_ARC_DEGREES * DEGREES_TO_RADIANS; + const relative = pageDialRelativeAngle(angleRadians); + if (relative <= usableArcRadians) return relative / usableArcRadians; + + // The only invalid portion is the top dead zone. Its midpoint is a stable + // tie-breaker: the exact top angle belongs to the first page, the clockwise + // half belongs to the first page, and the counter-clockwise half belongs to + // the last page. This prevents an accidental first/last wrap. + const distanceFromEnd = relative - usableArcRadians; const distanceFromStart = FULL_TURN_RADIANS - relative; return distanceFromStart <= distanceFromEnd ? 0 : 1; } +export function pageDialProgressForPoint( + x: number, + y: number, + diameter: number, +): number { + "worklet"; + const center = diameter / 2; + if (x === center && y === center) return 0; + return pageDialProgressForAngle(Math.atan2(y - center, x - center)); +} + +export function pageDialIndexForProgress( + progress: number, + pageCount: number, +): number { + "worklet"; + if (pageCount <= 1 || !Number.isFinite(pageCount)) return 0; + const safeProgress = Number.isFinite(progress) ? progress : 0; + return Math.round(clamp(safeProgress, 0, 1) * (pageCount - 1)); +} + export function pageDialIndexForAngle( angleRadians: number, pageCount: number, ): number { "worklet"; - if (pageCount <= 1) return 0; - return Math.round(pageDialProgressForAngle(angleRadians) * (pageCount - 1)); + return pageDialIndexForProgress( + pageDialProgressForAngle(angleRadians), + pageCount, + ); } export function pageDialIndexForPoint( @@ -54,8 +166,185 @@ export function pageDialIndexForPoint( y: number, diameter: number, pageCount: number, +): number { + "worklet"; + return pageDialIndexForProgress( + pageDialProgressForPoint(x, y, diameter), + pageCount, + ); +} + +export function pageDialPointForAngle( + angleRadians: number, + diameter: number, + radius = diameter / 2, +): PageDialPoint { + "worklet"; + const center = diameter / 2; + return { + x: center + Math.cos(angleRadians) * radius, + y: center + Math.sin(angleRadians) * radius, + }; +} + +export function pageDialPointForProgress( + progress: number, + diameter: number, + radius = getPageDialRingRadius(diameter), +): PageDialPoint { + "worklet"; + return pageDialPointForAngle( + pageDialAngleForProgress(progress), + diameter, + radius, + ); +} + +export const pageDialKnobPointForProgress = pageDialPointForProgress; + +export function getPageDialRingRadius( + diameter: number, + ringThickness = diameter * PAGE_DIAL_RING_THICKNESS_RATIO, +): number { + "worklet"; + return diameter / 2 - ringThickness / 2; +} + +export function getPageDialRingHitRegion( + diameter: number, +): PageDialRingHitRegion { + "worklet"; + return { + innerRadius: diameter * PAGE_DIAL_RING_HIT_INNER_RADIUS_RATIO, + outerRadius: diameter * PAGE_DIAL_RING_HIT_OUTER_RADIUS_RATIO, + }; +} + +export const pageDialRingHitRegion = getPageDialRingHitRegion; + +export function pageDialRadialDistanceForPoint( + x: number, + y: number, + diameter: number, ): number { "worklet"; const center = diameter / 2; - return pageDialIndexForAngle(Math.atan2(y - center, x - center), pageCount); + return Math.hypot(x - center, y - center); +} + +export function pageDialPointIsInRingHitRegion( + x: number, + y: number, + diameter: number, +): boolean { + "worklet"; + const distance = pageDialRadialDistanceForPoint(x, y, diameter); + const region = getPageDialRingHitRegion(diameter); + return distance >= region.innerRadius && distance <= region.outerRadius; +} + +export const isPageDialRingHit = pageDialPointIsInRingHitRegion; + +export function getPageDialControlCenterOffset(diameter: number): number { + "worklet"; + return diameter * PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO; +} + +export function getPageDialCardinalPoints( + diameter: number, + offset = getPageDialControlCenterOffset(diameter), +): PageDialCardinalPoints { + "worklet"; + const center = diameter / 2; + return { + top: { x: center, y: center - offset }, + right: { x: center + offset, y: center }, + bottom: { x: center, y: center + offset }, + left: { x: center - offset, y: center }, + }; +} + +export const pageDialCardinalPoints = getPageDialCardinalPoints; + +export function getPageDialControlSize(diameter: number): number { + "worklet"; + return Math.max(PAGE_DIAL_MIN_CONTROL_SIZE, diameter * 0.31); +} + +/** + * Returns whether a touch belongs to one of the four button hit boxes. The + * ring detector uses this only at gesture start so a ring drag can pass over a + * button without being interrupted after it has begun. + */ +export function pageDialPointIsInControlHitTarget( + x: number, + y: number, + diameter: number, +): boolean { + "worklet"; + const size = getPageDialControlSize(diameter); + const halfSize = size / 2; + const points = getPageDialCardinalPoints(diameter); + return ( + (Math.abs(x - points.top.x) <= halfSize && + Math.abs(y - points.top.y) <= halfSize) || + (Math.abs(x - points.right.x) <= halfSize && + Math.abs(y - points.right.y) <= halfSize) || + (Math.abs(x - points.bottom.x) <= halfSize && + Math.abs(y - points.bottom.y) <= halfSize) || + (Math.abs(x - points.left.x) <= halfSize && + Math.abs(y - points.left.y) <= halfSize) + ); +} + +export const pageDialPointHitsControl = pageDialPointIsInControlHitTarget; + +function pointAtRadius( + diameter: number, + angleDegrees: number, + radius: number, +): PageDialPoint { + return pageDialPointForAngle( + angleDegrees * DEGREES_TO_RADIANS, + diameter, + radius, + ); +} + +/** + * X dividers are split at the center disk rather than drawing underneath it. + * This keeps both diagonals visibly flush with the blue disk edge and avoids + * relying on paint order to hide a line through the center label. + */ +export function getPageDialDividerSegments( + diameter: number, + innerDiskDiameter = diameter * PAGE_DIAL_INNER_DISK_DIAMETER_RATIO, + centerDiskDiameter = diameter * PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO, +): readonly PageDialLineSegment[] { + const outerRadius = innerDiskDiameter / 2; + const innerRadius = centerDiskDiameter / 2; + return [ + { + start: pointAtRadius(diameter, -135, outerRadius), + end: pointAtRadius(diameter, -135, innerRadius), + }, + { + start: pointAtRadius(diameter, -45, outerRadius), + end: pointAtRadius(diameter, -45, innerRadius), + }, + { + start: pointAtRadius(diameter, 45, innerRadius), + end: pointAtRadius(diameter, 45, outerRadius), + }, + { + start: pointAtRadius(diameter, 135, innerRadius), + end: pointAtRadius(diameter, 135, outerRadius), + }, + ]; +} + +export const pageDialDividerSegments = getPageDialDividerSegments; + +export function getPageDialCanvasOverscan(diameter: number): number { + return diameter * PAGE_DIAL_CANVAS_OVERSCAN_RATIO; } diff --git a/apps/mobile/src/features/field/page-dial/page-dial.tsx b/apps/mobile/src/features/field/page-dial/page-dial.tsx index f225d47c..82b9c325 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial.tsx @@ -7,13 +7,18 @@ import { type SharedValue, } from "react-native-reanimated"; import type { DrillTerminology } from "@eight2five/mobile/drill"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; import { PageDialCanvas } from "./page-dial-canvas"; import { PageDialControls } from "./page-dial-controls"; import { triggerPageDialHaptic, usePageDialGesture } from "./page-dial-gesture"; +import { normalizePageIndex } from "./page-dial-math"; -function animateIndex(sharedValue: SharedValue, index: number): void { - sharedValue.value = withTiming(index, { duration: 120 }); +function animateProgress( + sharedValue: SharedValue, + progress: number, +): void { + sharedValue.value = withTiming(progress, { duration: 180 }); } export function PageDial({ @@ -24,7 +29,13 @@ export function PageDial({ terminology, activeColor, trackColor, + innerColor, + backgroundColor, + foregroundColor, + dividerColor, onSelectIndex, + onSelectDrill, + onSelectPerformer, }: { readonly diameter: number; readonly selectedIndex: number; @@ -33,52 +44,98 @@ export function PageDial({ readonly terminology: DrillTerminology; readonly activeColor: string; readonly trackColor: string; + readonly innerColor?: string; + readonly backgroundColor?: string; + readonly foregroundColor?: string; + readonly dividerColor?: string; readonly onSelectIndex: (index: number) => void; + readonly onSelectDrill?: () => void; + readonly onSelectPerformer?: () => void; }) { - const provisionalIndex = useSharedValue(Math.max(0, selectedIndex)); + const theme = useEight2FiveTheme(); + const provisionalProgress = useSharedValue( + normalizePageIndex(Math.max(0, selectedIndex), pageCount), + ); React.useEffect(() => { - animateIndex(provisionalIndex, Math.max(0, selectedIndex)); - }, [provisionalIndex, selectedIndex]); + animateProgress( + provisionalProgress, + normalizePageIndex(Math.max(0, selectedIndex), pageCount), + ); + }, [pageCount, provisionalProgress, selectedIndex]); const gesture = usePageDialGesture({ diameter, pageCount, - provisionalIndex, + selectedIndex, + provisionalProgress, onCommitIndex: onSelectIndex, }); const selectFromButton = React.useCallback( (index: number) => { + if (pageCount <= 0) return; const bounded = Math.max(0, Math.min(pageCount - 1, index)); if (bounded === selectedIndex) return; - animateIndex(provisionalIndex, bounded); + animateProgress( + provisionalProgress, + normalizePageIndex(bounded, pageCount), + ); triggerPageDialHaptic(); onSelectIndex(bounded); }, - [onSelectIndex, pageCount, provisionalIndex, selectedIndex], + [onSelectIndex, pageCount, provisionalProgress, selectedIndex], ); + const resolvedInnerColor = innerColor ?? theme.surfaceRaised; + const resolvedBackgroundColor = backgroundColor ?? theme.background; + const resolvedForegroundColor = foregroundColor ?? theme.text; + const resolvedDividerColor = dividerColor ?? theme.border; + return ( - - - - selectFromButton(selectedIndex - 1)} - onNext={() => - selectFromButton(selectedIndex < 0 ? 0 : selectedIndex + 1) - } + + + + - - + + selectFromButton(selectedIndex - 1)} + onNext={() => + selectFromButton(selectedIndex < 0 ? 0 : selectedIndex + 1) + } + onSelectDrill={onSelectDrill} + onSelectPerformer={onSelectPerformer} + /> + ); } diff --git a/packages/mobile/src/field/render/page-dial-canvas.tsx b/packages/mobile/src/field/render/page-dial-canvas.tsx index 4a8cbfcc..97ebd9ad 100644 --- a/packages/mobile/src/field/render/page-dial-canvas.tsx +++ b/packages/mobile/src/field/render/page-dial-canvas.tsx @@ -1,7 +1,17 @@ import React from "react"; -import { Canvas, Circle, Group, Path, Skia } from "@shopify/react-native-skia"; +import { Canvas, Circle, Path, Shadow, Skia } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; +export interface FieldPageDialPoint { + readonly x: number; + readonly y: number; +} + +export interface FieldPageDialLineSegment { + readonly start: FieldPageDialPoint; + readonly end: FieldPageDialPoint; +} + export interface FieldPageDialCanvasProps { readonly diameter: number; readonly progress: SharedValue; @@ -10,10 +20,59 @@ export interface FieldPageDialCanvasProps { readonly activeColor: string; readonly trackColor: string; readonly innerColor?: string; + readonly backgroundColor?: string; readonly foregroundColor?: string; + readonly dividerColor?: string; + readonly dividerSegments?: readonly FieldPageDialLineSegment[]; readonly testID?: string; } +const INNER_DISK_DIAMETER_RATIO = 0.86; +const CENTER_DISK_DIAMETER_RATIO = 0.3; +const RING_THICKNESS_RATIO = 0.075; +const KNOB_DIAMETER_RATIO = 0.16; +const CANVAS_OVERSCAN_RATIO = 0.09; +const DIVIDER_STROKE_RATIO = 0.012; +const ACTIVE_OVERLAP_PROGRESS = 0.008; + +function pointAtRadius( + diameter: number, + angleDegrees: number, + radius: number, +): FieldPageDialPoint { + const center = diameter / 2; + const angle = (angleDegrees * Math.PI) / 180; + return { + x: center + Math.cos(angle) * radius, + y: center + Math.sin(angle) * radius, + }; +} + +function getDefaultDividerSegments( + diameter: number, +): readonly FieldPageDialLineSegment[] { + const outerRadius = (diameter * INNER_DISK_DIAMETER_RATIO) / 2; + const innerRadius = (diameter * CENTER_DISK_DIAMETER_RATIO) / 2; + return [ + { + start: pointAtRadius(diameter, -135, outerRadius), + end: pointAtRadius(diameter, -135, innerRadius), + }, + { + start: pointAtRadius(diameter, -45, outerRadius), + end: pointAtRadius(diameter, -45, innerRadius), + }, + { + start: pointAtRadius(diameter, 45, innerRadius), + end: pointAtRadius(diameter, 45, outerRadius), + }, + { + start: pointAtRadius(diameter, 135, innerRadius), + end: pointAtRadius(diameter, 135, outerRadius), + }, + ]; +} + export function FieldPageDialCanvas({ diameter, progress, @@ -22,14 +81,24 @@ export function FieldPageDialCanvas({ activeColor, trackColor, innerColor = "#222222", + backgroundColor = "transparent", foregroundColor = "#FFFFFF", + dividerColor = "rgba(255,255,255,0.28)", + dividerSegments, testID = "page-dial-canvas", }: FieldPageDialCanvasProps) { - const center = diameter / 2; - const ringThickness = diameter * 0.07; + const ringThickness = diameter * RING_THICKNESS_RATIO; const ringRadius = diameter / 2 - ringThickness / 2; + const knobRadius = (diameter * KNOB_DIAMETER_RATIO) / 2; + const canvasOverscan = diameter * CANVAS_OVERSCAN_RATIO; + const canvasDiameter = diameter + canvasOverscan * 2; + const center = canvasDiameter / 2; + const innerDiskRadius = (diameter * INNER_DISK_DIAMETER_RATIO) / 2; + const centerDiskRadius = (diameter * CENTER_DISK_DIAMETER_RATIO) / 2; + const segments = dividerSegments ?? getDefaultDividerSegments(diameter); + const trackPath = React.useMemo(() => { - const inset = ringThickness / 2; + const inset = canvasOverscan + ringThickness / 2; return Skia.PathBuilder.Make() .addArc( Skia.XYWHRect( @@ -42,24 +111,74 @@ export function FieldPageDialCanvas({ usableArcDegrees, ) .build(); - }, [diameter, ringThickness, startAngleDegrees, usableArcDegrees]); + }, [ + canvasOverscan, + diameter, + ringThickness, + startAngleDegrees, + usableArcDegrees, + ]); + + const dividerPaths = React.useMemo( + () => + segments.map(({ start, end }) => + Skia.PathBuilder.Make() + .moveTo(start.x + canvasOverscan, start.y + canvasOverscan) + .lineTo(end.x + canvasOverscan, end.y + canvasOverscan) + .build(), + ), + [canvasOverscan, segments], + ); + + const normalizedProgress = useDerivedValue(() => { + const value = progress.value; + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0; + }); + const activeProgress = useDerivedValue(() => { + const value = normalizedProgress.value; + return Math.min( + 1, + Math.max(ACTIVE_OVERLAP_PROGRESS, value + ACTIVE_OVERLAP_PROGRESS), + ); + }); const knobX = useDerivedValue(() => { const angle = - ((startAngleDegrees + progress.value * usableArcDegrees) * Math.PI) / 180; + ((startAngleDegrees + normalizedProgress.value * usableArcDegrees) * + Math.PI) / + 180; return center + Math.cos(angle) * ringRadius; }); const knobY = useDerivedValue(() => { const angle = - ((startAngleDegrees + progress.value * usableArcDegrees) * Math.PI) / 180; + ((startAngleDegrees + normalizedProgress.value * usableArcDegrees) * + Math.PI) / + 180; return center + Math.sin(angle) * ringRadius; }); - return ( - + + + + + {/* Paint the complete track first, then overlap it with the active arc. */} - - - - ( + - + ))} + + {/* The center disk is drawn last so the X ends exactly at its edge. */} + + {/* A larger, overscanned knob keeps its soft offset shadow inside the canvas. */} + + + ); } From 421e381129b0b9d74cb9fc635cc308fff5d4037a Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 18:40:05 -0500 Subject: [PATCH 075/101] feat(field): separate live and drill hud --- .../components/drill-selection-dialog.tsx | 161 +++++++++ .../__tests__/field-overlay-layout.test.ts | 64 +++- .../__tests__/live-position-hud-state.test.ts | 46 +++ .../__tests__/coordinate-panel-state.test.ts | 138 -------- .../__tests__/drill-menu.test.ts | 38 --- .../coordinate-panel/connection-indicator.tsx | 62 ---- .../coordinate-panel-state.ts | 132 -------- .../coordinate-panel/coordinate-panel.tsx | 114 ------- .../coordinate-panel/drill-coordinate-row.tsx | 119 ------- .../coordinate-panel/drill-menu-state.ts | 25 -- .../field/coordinate-panel/drill-menu.tsx | 60 ---- .../coordinate-panel/live-coordinate-row.tsx | 48 --- .../transition-metric-cell.tsx | 65 ---- .../features/field/field-overlay-layout.tsx | 139 ++++++-- .../src/features/field/field-screen.tsx | 243 ++++++++++---- .../features/field/live-position-hud-state.ts | 63 ++++ .../src/features/field/live-position-hud.tsx | 311 ++++++++++++++++++ .../features/field/tag-connection-dialog.tsx | 42 +++ .../field/use-field-screen-controller.ts | 41 ++- .../tag-connection-lifecycle.test.ts | 4 +- .../settings/tag-connection-lifecycle.ts | 13 +- .../settings/tag-connection-screen.tsx | 45 ++- 22 files changed, 1033 insertions(+), 940 deletions(-) create mode 100644 apps/mobile/src/features/drill/components/drill-selection-dialog.tsx create mode 100644 apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts delete mode 100644 apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts delete mode 100644 apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts delete mode 100644 apps/mobile/src/features/field/coordinate-panel/connection-indicator.tsx delete mode 100644 apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts delete mode 100644 apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx delete mode 100644 apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx delete mode 100644 apps/mobile/src/features/field/coordinate-panel/drill-menu-state.ts delete mode 100644 apps/mobile/src/features/field/coordinate-panel/drill-menu.tsx delete mode 100644 apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx delete mode 100644 apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx create mode 100644 apps/mobile/src/features/field/live-position-hud-state.ts create mode 100644 apps/mobile/src/features/field/live-position-hud.tsx create mode 100644 apps/mobile/src/features/field/tag-connection-dialog.tsx diff --git a/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx b/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx new file mode 100644 index 00000000..09a9cef2 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { useWindowDimensions } from "react-native"; +import { CircleCheck, X } from "lucide-react-native"; +import type { Drill, DrillTerms } from "@eight2five/mobile/drill"; +import { FlatList } from "@eight2five/ui/components/flat-list"; +import { Heading } from "@eight2five/ui/components/heading"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { + Modal, + ModalBackdrop, + ModalCloseButton, + ModalContent, + ModalHeader, +} from "@eight2five/ui/components/modal"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { resolveDrillIcon } from "../drill-icons"; +import { formatDrillCount } from "../drill-management"; + +export interface DrillSelectionEntry { + readonly drill: Drill; + readonly pageCount: number; +} + +export function DrillSelectionDialog({ + entries, + terms, + activeDrillId, + isOpen, + disabled, + onClose, + onSelect, +}: { + readonly entries: readonly DrillSelectionEntry[]; + readonly terms: DrillTerms; + readonly activeDrillId: string | null; + readonly isOpen: boolean; + readonly disabled: boolean; + readonly onClose: () => void; + readonly onSelect: (drillId: string) => void; +}) { + const { height } = useWindowDimensions(); + if (!isOpen) return null; + return ( + + + + + Select Drill + + + + + + + + ); +} + +/** Shared selection-only list body for field and future drill pickers. */ +export function DrillSelectionList({ + entries, + terms, + activeDrillId, + disabled, + maxHeight, + onSelect, +}: { + readonly entries: readonly DrillSelectionEntry[]; + readonly terms: DrillTerms; + readonly activeDrillId: string | null; + readonly disabled: boolean; + readonly maxHeight?: number; + readonly onSelect: (drillId: string) => void; +}) { + const theme = useEight2FiveTheme(); + const renderItem = React.useCallback( + ({ item }: { item: DrillSelectionEntry }) => { + const active = item.drill.id === activeDrillId; + const DrillIcon = resolveDrillIcon(item.drill.metadata?.lucideIcon); + return ( + onSelect(item.drill.id)} + style={{ + minHeight: 64, + justifyContent: "center", + borderRadius: eight2FiveRadii.md, + borderWidth: active ? 2 : 1, + borderColor: active ? theme.accent : theme.border, + backgroundColor: active ? theme.accentSoft : theme.surfaceRaised, + padding: eight2FiveSpacing.sm, + }} + testID={`drill-selection-${item.drill.id}`} + > + + + + + {item.drill.name} + + + {formatDrillCount(item.pageCount, terms)} + + + {active ? ( + + ) : null} + + + ); + }, + [activeDrillId, disabled, onSelect, terms, theme], + ); + + return ( + entry.drill.id} + renderItem={renderItem} + style={maxHeight === undefined ? undefined : { maxHeight }} + contentContainerStyle={{ gap: eight2FiveSpacing.sm }} + ListEmptyComponent={ + No drills uploaded. + } + testID="drill-selection-list" + /> + ); +} diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts index efd9da62..5875de42 100644 --- a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts +++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts @@ -3,7 +3,7 @@ import { getFieldOverlayMetrics } from "../field-overlay-layout"; const insets = { top: 24, right: 10, bottom: 20, left: 10 }; describe("Field overlay layout", () => { - test("places a safe-area-aware HUD and right-side dial in landscape", () => { + test("places a safe-area-aware HUD and right live/dial stack in landscape", () => { const layout = getFieldOverlayMetrics({ width: 844, height: 390, @@ -11,15 +11,19 @@ describe("Field overlay layout", () => { insets, }); - expect(layout.dialDiameter).toBeGreaterThanOrEqual(148); - expect(layout.dialDiameter).toBeLessThanOrEqual(172); - expect(layout.hudStyle.top).toBe(40); - expect(layout.hudStyle.left).toBe(26); - expect(layout.hudStyle.width).toBeLessThanOrEqual(844 * 0.72); - expect(layout.dialStyle.right).toBe(26); + expect(layout.controlDiameter).toBeGreaterThanOrEqual(140); + expect(layout.controlDiameter).toBeLessThanOrEqual(164); + expect(layout.hudStyle.top).toBe(38); + expect(layout.hudStyle.left).toBe(24); + expect(layout.hudWidth).toBeGreaterThan(0); + expect(layout.liveStyle.right).toBe(24); + expect(layout.dialStyle.right).toBe(24); + expect(Number(layout.dialStyle.top)).toBe( + Number(layout.liveStyle.top) + layout.controlDiameter + layout.controlGap, + ); }); - test("centers the dial above the bottom inset and expands the HUD in portrait", () => { + test("centers the live/dial pair above the bottom inset in portrait", () => { const layout = getFieldOverlayMetrics({ width: 390, height: 844, @@ -27,14 +31,48 @@ describe("Field overlay layout", () => { insets, }); - expect(layout.dialDiameter).toBeGreaterThanOrEqual(140); - expect(layout.dialDiameter).toBeLessThanOrEqual(156); + expect(layout.controlDiameter).toBeGreaterThanOrEqual(140); + expect(layout.controlDiameter).toBeLessThanOrEqual(156); expect(layout.hudStyle.left).toBe(22); - expect(layout.hudStyle.right).toBe(22); expect(layout.dialStyle.bottom).toBe(32); - expect(layout.dialStyle.left).toBe( + expect(layout.liveStyle.left).toBe( insets.left + - (390 - insets.left - insets.right - layout.dialDiameter) / 2, + (390 - + insets.left - + insets.right - + (layout.controlDiameter * 2 + layout.controlGap)) / + 2, + ); + expect(Number(layout.dialStyle.left)).toBe( + Number(layout.liveStyle.left) + + layout.controlDiameter + + layout.controlGap, + ); + }); + + test("shrinks both portrait controls together on a narrow safe width", () => { + const layout = getFieldOverlayMetrics({ + width: 360, + height: 740, + landscape: false, + insets: { top: 20, right: 18, bottom: 20, left: 18 }, + }); + const availableWidth = 360 - 18 - 18 - layout.outerPadding * 2; + expect(layout.controlDiameter * 2 + layout.controlGap).toBeLessThanOrEqual( + availableWidth, + ); + }); + + test("uses the full safe landscape width for the drill-off live pill", () => { + const layout = getFieldOverlayMetrics({ + width: 844, + height: 390, + landscape: true, + insets, + controlPairVisible: false, + }); + expect(layout.hudWidth).toBe( + 844 - insets.left - insets.right - layout.outerPadding * 2, ); }); }); diff --git a/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts new file mode 100644 index 00000000..fc8c6b84 --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts @@ -0,0 +1,46 @@ +import { EMPTY_FIELD_LIVE_POSITION_STATE } from "@eight2five/mobile/field"; + +import { getTargetDistancePresentation } from "../live-position-hud-state"; + +describe("live position HUD state", () => { + test("computes physical 8-to-5 distance and threshold tones", () => { + const live = { + ...EMPTY_FIELD_LIVE_POSITION_STATE, + connectionState: "connected" as const, + position: { xMeters: 0, yMeters: 0 }, + isStale: false, + }; + expect( + getTargetDistancePresentation({ + live, + target: { xMeters: 0.28575, yMeters: 0 }, + greenThresholdSteps: 0.5, + yellowThresholdSteps: 1, + }), + ).toMatchObject({ value: "0.5 steps", tone: "success" }); + expect( + getTargetDistancePresentation({ + live, + target: { xMeters: 0.5715, yMeters: 0 }, + greenThresholdSteps: 0.5, + yellowThresholdSteps: 1, + }).tone, + ).toBe("warning"); + expect( + getTargetDistancePresentation({ + live, + target: { xMeters: 1.143, yMeters: 0 }, + greenThresholdSteps: 0.5, + yellowThresholdSteps: 1, + }).tone, + ).toBe("danger"); + expect( + getTargetDistancePresentation({ + live: { ...live, isStale: true }, + target: { xMeters: 0, yMeters: 0 }, + greenThresholdSteps: 0.5, + yellowThresholdSteps: 1, + }), + ).toEqual({ value: "–", tone: "muted" }); + }); +}); diff --git a/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts b/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts deleted file mode 100644 index b4deed79..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/__tests__/coordinate-panel-state.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { DrillSet } from "@eight2five/mobile/drill"; -import { drillGridPointToFieldPoint } from "@eight2five/mobile/field"; - -import { - areCoordinatePanelControlsDisabled, - getDrillCoordinatePresentation, - getLiveCoordinatePresentation, -} from "../coordinate-panel-state"; - -const first: DrillSet = { - id: "s1", - drillId: "d1", - ordinal: 0, - number: 31, - kind: "set", - countsFromPrevious: 0, - measureRange: { start: 122, end: 125 }, - position: { xSteps: -16, ySteps: 7 }, -}; -const second: DrillSet = { - id: "s2", - drillId: "d1", - ordinal: 1, - number: 32, - kind: "set", - countsFromPrevious: 8, - measureRange: { start: 126, end: 129 }, - position: { xSteps: -8, ySteps: 7 }, -}; - -describe("coordinate panel state", () => { - test("presents waiting, live, and stale physical-position states", () => { - expect( - getLiveCoordinatePresentation({ - connectionState: "idle", - isStale: false, - }), - ).toMatchObject({ - primary: "Waiting for live position", - secondary: "Connect a PANS tag to begin", - muted: true, - }); - const livePosition = drillGridPointToFieldPoint(second.position); - expect( - getLiveCoordinatePresentation({ - connectionState: "connected", - position: livePosition, - isStale: false, - }).primary, - ).toContain("Side 1"); - expect( - getLiveCoordinatePresentation({ - connectionState: "disconnected", - position: livePosition, - isStale: true, - }), - ).toMatchObject({ statusLabel: "Last known position", muted: true }); - }); - - test("uses the selected terminology and separate count/measure fields", () => { - expect( - getDrillCoordinatePresentation({ - metricMode: "step-size", - terminology: "sets", - }), - ).toEqual({ - term: "Set", - set: "–", - counts: "–", - measures: "–", - metricLabel: "Step Size", - metric: "–", - coordinate: null, - emptyMessage: "No drill set selected", - }); - expect( - getDrillCoordinatePresentation({ - metricMode: "step-size", - terminology: "pages", - }), - ).toMatchObject({ - term: "Page", - emptyMessage: "No drill page selected", - }); - }); - - test("toggles between step-size and crossing-count metrics", () => { - const stepSize = getDrillCoordinatePresentation({ - page: second, - previousPage: first, - metricMode: "step-size", - terminology: "sets", - }); - const crossingCounts = getDrillCoordinatePresentation({ - page: second, - previousPage: first, - metricMode: "crossing-counts", - terminology: "sets", - }); - - expect(stepSize).toMatchObject({ - term: "Set", - set: "32", - counts: "8", - measures: "126–129", - metricLabel: "Step Size", - metric: "8 to 5", - }); - expect(crossingCounts.metricLabel).toBe("xCounts"); - }); - - test("shows zero counts for the first set instead of using an unavailable marker", () => { - expect( - getDrillCoordinatePresentation({ - page: first, - metricMode: "step-size", - terminology: "sets", - }).counts, - ).toBe("0"); - }); - - test("disables controls until storage and drill data are ready", () => { - expect( - areCoordinatePanelControlsDisabled({ - settingsReady: false, - loadingDrills: false, - selectionBusy: false, - }), - ).toBe(true); - expect( - areCoordinatePanelControlsDisabled({ - settingsReady: true, - loadingDrills: false, - selectionBusy: false, - }), - ).toBe(false); - }); -}); diff --git a/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts b/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts deleted file mode 100644 index ac2b42ea..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/__tests__/drill-menu.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Drill } from "@eight2five/mobile/drill"; - -import { createDrillMenuActions } from "../drill-menu-state"; - -const drills: Drill[] = [ - { - id: "one", - name: "Opener 2026", - fieldPreset: "football-nfhs", - createdAt: 1, - updatedAt: 1, - }, - { - id: "two", - name: "Closer", - fieldPreset: "football-nfhs", - createdAt: 2, - updatedAt: 2, - }, -]; - -describe("active drill menu", () => { - test("marks the active drill and preserves the no-drill action", () => { - expect(createDrillMenuActions(drills, "one")).toMatchObject([ - { id: "__no-drill__", state: "off" }, - { id: "one", state: "on" }, - { id: "two", state: "off" }, - ]); - }); - - test("disables every native action while storage is busy", () => { - expect( - createDrillMenuActions(drills, null, true).every( - (action) => action.attributes?.disabled, - ), - ).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/field/coordinate-panel/connection-indicator.tsx b/apps/mobile/src/features/field/coordinate-panel/connection-indicator.tsx deleted file mode 100644 index b0552714..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/connection-indicator.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Icon } from "@eight2five/ui/components/icon"; -import { HStack } from "@eight2five/ui/components/hstack"; -import { - BluetoothConnected, - BluetoothOff, - LoaderCircle, - RefreshCw, - TriangleAlert, -} from "lucide-react-native"; -import type { FieldConnectionState } from "@eight2five/mobile/field"; - -const CONNECTION_PRESENTATION = { - idle: { icon: BluetoothOff, label: "PANS tag idle", color: "#AAB0BA" }, - connecting: { - icon: LoaderCircle, - label: "Connecting to PANS tag", - color: "#6FA0E1", - }, - connected: { - icon: BluetoothConnected, - label: "PANS tag connected", - color: "#68C36D", - }, - reconnecting: { - icon: RefreshCw, - label: "Reconnecting to PANS tag", - color: "#E2B84F", - }, - disconnected: { - icon: BluetoothOff, - label: "PANS tag disconnected", - color: "#AAB0BA", - }, - error: { - icon: TriangleAlert, - label: "PANS tag connection error", - color: "#E16B6B", - }, -} as const; - -export function ConnectionIndicator({ - state, -}: { - state: FieldConnectionState; -}) { - const presentation = CONNECTION_PRESENTATION[state]; - return ( - - - - ); -} diff --git a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts deleted file mode 100644 index 39a4db85..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel-state.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - drillGridPointToMarchingCoordinate, - fieldPointToMarchingCoordinate, - formatMarchingFrontBack, - formatMarchingSide, - type FieldLivePositionState, -} from "@eight2five/mobile/field"; -import { - formatSetName, - getDrillTerms, - type DrillSet, - type DrillTerminology, -} from "@eight2five/mobile/drill"; -import type { TransitionMetricMode } from "@eight2five/mobile/settings"; -import type { FieldPresetId } from "@eight2five/drill-schema"; - -import { getTransitionPresentation } from "../../drill/transition-presentation"; - -export interface CoordinateLines { - readonly side: string; - readonly frontBack: string; -} - -export interface LiveCoordinatePresentation { - readonly statusLabel?: string; - readonly primary: string; - readonly secondary: string; - readonly muted: boolean; -} - -export interface DrillCoordinatePresentation { - readonly term: "Page" | "Set"; - readonly set: string; - readonly counts: string; - readonly measures: string; - readonly metricLabel: "Step Size" | "xCounts"; - readonly metric: string; - readonly coordinate: CoordinateLines | null; - readonly emptyMessage?: string; -} - -export function areCoordinatePanelControlsDisabled({ - settingsReady, - loadingDrills, - selectionBusy, -}: { - readonly settingsReady: boolean; - readonly loadingDrills: boolean; - readonly selectionBusy: boolean; -}): boolean { - return !settingsReady || loadingDrills || selectionBusy; -} - -export function formatDrillCoordinateLines( - position: DrillSet["position"], - fieldPreset: FieldPresetId = "football-nfhs", -): CoordinateLines { - const coordinate = drillGridPointToMarchingCoordinate(position, fieldPreset); - return { - side: formatMarchingSide(coordinate.side), - frontBack: formatMarchingFrontBack(coordinate.frontBack, fieldPreset), - }; -} - -export function getLiveCoordinatePresentation( - live: FieldLivePositionState, - fieldPreset: FieldPresetId = "football-nfhs", -): LiveCoordinatePresentation { - if (!live.position) { - return { - primary: "Waiting for live position", - secondary: - live.connectionState === "error" && live.errorMessage - ? live.errorMessage - : "Connect a PANS tag to begin", - muted: true, - }; - } - const coordinate = fieldPointToMarchingCoordinate(live.position, fieldPreset); - return { - ...(live.isStale ? { statusLabel: "Last known position" } : {}), - primary: formatMarchingSide(coordinate.side), - secondary: formatMarchingFrontBack(coordinate.frontBack, fieldPreset), - muted: live.isStale, - }; -} - -export function getDrillCoordinatePresentation({ - page, - previousPage, - metricMode, - fieldPreset = "football-nfhs", - terminology, -}: { - readonly page?: DrillSet; - readonly previousPage?: DrillSet; - readonly metricMode: TransitionMetricMode; - readonly fieldPreset?: FieldPresetId; - readonly terminology: DrillTerminology; -}): DrillCoordinatePresentation { - const terms = getDrillTerms(terminology); - const metricLabel = metricMode === "step-size" ? "Step Size" : "xCounts"; - if (!page) { - return { - term: terms.singular, - set: "–", - counts: "–", - measures: "–", - metricLabel, - metric: "–", - coordinate: null, - emptyMessage: `No drill ${terms.lowercaseSingular} selected`, - }; - } - const transition = getTransitionPresentation(previousPage, page); - return { - term: terms.singular, - set: formatSetName(page), - counts: String(page.countsFromPrevious), - measures: page.measureRange - ? page.measureRange.start === page.measureRange.end - ? String(page.measureRange.start) - : `${page.measureRange.start}–${page.measureRange.end}` - : "–", - metricLabel, - metric: - metricMode === "step-size" - ? transition.stepSize - : transition.crossingCounts, - coordinate: formatDrillCoordinateLines(page.position, fieldPreset), - }; -} diff --git a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx b/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx deleted file mode 100644 index 39f23c56..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/coordinate-panel.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Box } from "@eight2five/ui/components/box"; -import { HStack } from "@eight2five/ui/components/hstack"; -import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; -import type { FieldLivePositionState } from "@eight2five/mobile/field"; -import type { - Drill, - DrillPage, - DrillTerminology, -} from "@eight2five/mobile/drill"; -import type { TransitionMetricMode } from "@eight2five/mobile/settings"; -import type { FieldPresetId } from "@eight2five/drill-schema"; - -import { ConnectionIndicator } from "./connection-indicator"; -import { DrillCoordinateRow } from "./drill-coordinate-row"; -import { DrillMenu } from "./drill-menu"; -import { LiveCoordinateRow } from "./live-coordinate-row"; - -export interface CoordinatePanelProps { - readonly landscape: boolean; - readonly live: FieldLivePositionState; - readonly drillFeaturesEnabled: boolean; - readonly drills: readonly Drill[]; - readonly activeDrill?: Drill; - readonly selectedPage?: DrillPage; - readonly previousPage?: DrillPage; - readonly terminology: DrillTerminology; - readonly metricMode: TransitionMetricMode; - readonly fieldPreset: FieldPresetId; - readonly controlsDisabled: boolean; - readonly error?: Error; - readonly onSelectDrill: (drillId: string | null) => void; - readonly onToggleMetric: () => void; -} - -export function CoordinatePanel({ - landscape, - live, - drillFeaturesEnabled, - drills, - activeDrill, - selectedPage, - previousPage, - terminology, - metricMode, - fieldPreset, - controlsDisabled, - error, - onSelectDrill, - onToggleMetric, -}: CoordinatePanelProps) { - const height = drillFeaturesEnabled ? (landscape ? 132 : 188) : 76; - return ( - - - - - {drillFeaturesEnabled ? ( - - ) : null} - - {drillFeaturesEnabled ? ( - <> - - - - ) : null} - {error ? ( - - {error.message} - - ) : null} - - ); -} diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx b/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx deleted file mode 100644 index 795e69aa..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/drill-coordinate-row.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { HStack } from "@eight2five/ui/components/hstack"; -import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; -import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; -import type { TransitionMetricMode } from "@eight2five/mobile/settings"; -import type { FieldPresetId } from "@eight2five/drill-schema"; - -import { getDrillCoordinatePresentation } from "./coordinate-panel-state"; -import { TransitionMetricCell } from "./transition-metric-cell"; - -function MetadataCell({ label, value }: { label: string; value: string }) { - return ( - - - {label} - - - {value} - - - ); -} - -function DrillCoordinate({ - coordinate, - emptyMessage, -}: Pick< - ReturnType, - "coordinate" | "emptyMessage" ->) { - return ( - - - {coordinate?.side ?? emptyMessage} - - {coordinate ? ( - - {coordinate.frontBack} - - ) : null} - - ); -} - -export function DrillCoordinateRow({ - page, - previousPage, - terminology, - metricMode, - fieldPreset, - landscape, - metricToggleDisabled, - onToggleMetric, -}: { - readonly page?: DrillSet; - readonly previousPage?: DrillSet; - readonly terminology: DrillTerminology; - readonly metricMode: TransitionMetricMode; - readonly fieldPreset: FieldPresetId; - readonly landscape: boolean; - readonly metricToggleDisabled: boolean; - readonly onToggleMetric: () => void; -}) { - const presentation = getDrillCoordinatePresentation({ - page, - previousPage, - metricMode, - fieldPreset, - terminology, - }); - const metadata = ( - - - - - - - ); - - return landscape ? ( - - {metadata} - - - ) : ( - - {metadata} - - - ); -} diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-menu-state.ts b/apps/mobile/src/features/field/coordinate-panel/drill-menu-state.ts deleted file mode 100644 index 40aa865a..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/drill-menu-state.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { MenuAction } from "@expo/ui/community/menu"; -import type { Drill } from "@eight2five/mobile/drill"; - -export const NO_DRILL_ACTION_ID = "__no-drill__"; - -export function createDrillMenuActions( - drills: readonly Drill[], - activeDrillId: string | null, - disabled = false, -): MenuAction[] { - return [ - { - id: NO_DRILL_ACTION_ID, - title: "No drill selected", - state: activeDrillId === null ? "on" : "off", - attributes: { disabled }, - }, - ...drills.map((drill) => ({ - id: drill.id, - title: drill.name, - state: activeDrillId === drill.id ? ("on" as const) : ("off" as const), - attributes: { disabled }, - })), - ]; -} diff --git a/apps/mobile/src/features/field/coordinate-panel/drill-menu.tsx b/apps/mobile/src/features/field/coordinate-panel/drill-menu.tsx deleted file mode 100644 index c0a1a56d..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/drill-menu.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { MenuView } from "@expo/ui/community/menu"; -import { HStack } from "@eight2five/ui/components/hstack"; -import { Icon } from "@eight2five/ui/components/icon"; -import { Pressable } from "@eight2five/ui/components/pressable"; -import { Text } from "@eight2five/ui/components/text"; -import { ChevronDown, Flag } from "lucide-react-native"; -import type { Drill } from "@eight2five/mobile/drill"; - -import { createDrillMenuActions, NO_DRILL_ACTION_ID } from "./drill-menu-state"; - -export function DrillMenu({ - drills, - activeDrill, - disabled, - onSelect, -}: { - readonly drills: readonly Drill[]; - readonly activeDrill?: Drill; - readonly disabled: boolean; - readonly onSelect: (drillId: string | null) => void; -}) { - return ( - { - if (disabled) return; - onSelect( - nativeEvent.event === NO_DRILL_ACTION_ID ? null : nativeEvent.event, - ); - }} - testID="active-drill-menu" - > - - - {activeDrill ? ( - - ) : null} - - {activeDrill?.name ?? "No drill selected"} - - - - - - ); -} diff --git a/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx b/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx deleted file mode 100644 index da25b1be..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/live-coordinate-row.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; -import type { FieldLivePositionState } from "@eight2five/mobile/field"; -import type { FieldPresetId } from "@eight2five/drill-schema"; - -import { getLiveCoordinatePresentation } from "./coordinate-panel-state"; - -export function LiveCoordinateRow({ - live, - fieldPreset, -}: { - live: FieldLivePositionState; - fieldPreset: FieldPresetId; -}) { - const presentation = getLiveCoordinatePresentation(live, fieldPreset); - const color = presentation.muted ? "rgba(255,255,255,0.58)" : "#FFFFFF"; - return ( - - {presentation.statusLabel ? ( - - {presentation.statusLabel} - - ) : null} - - {presentation.primary} - - - {presentation.secondary} - - - ); -} diff --git a/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx b/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx deleted file mode 100644 index 5bffc72a..00000000 --- a/apps/mobile/src/features/field/coordinate-panel/transition-metric-cell.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Box } from "@eight2five/ui/components/box"; -import { Pressable } from "@eight2five/ui/components/pressable"; -import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; - -export function TransitionMetricCell({ - label, - value, - disabled, - onToggle, -}: { - readonly label: "Step Size" | "xCounts"; - readonly value: string; - readonly disabled: boolean; - readonly onToggle: () => void; -}) { - const stepSizeSelected = label === "Step Size"; - return ( - - - - {label} - - - {value} - - - - - - - - ); -} diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx index 26af3b3b..198d8dc3 100644 --- a/apps/mobile/src/features/field/field-overlay-layout.tsx +++ b/apps/mobile/src/features/field/field-overlay-layout.tsx @@ -7,9 +7,15 @@ import { export interface FieldOverlayMetrics { readonly outerPadding: number; + readonly controlGap: number; + readonly controlDiameter: number; + /** @deprecated Use controlDiameter. */ + readonly dialDiameter: number; + readonly hudWidth: number; + readonly hudListMaxHeight: number; readonly hudStyle: ViewStyle; + readonly liveStyle: ViewStyle; readonly dialStyle: ViewStyle; - readonly dialDiameter: number; } export function getFieldOverlayMetrics({ @@ -17,61 +23,111 @@ export function getFieldOverlayMetrics({ height, landscape, insets, + controlPairVisible = true, }: { readonly width: number; readonly height: number; readonly landscape: boolean; readonly insets: EdgeInsets; + readonly controlPairVisible?: boolean; }): FieldOverlayMetrics { - const outerPadding = landscape ? 16 : 12; - const availableWidth = Math.max(0, width - insets.left - insets.right); - const dialDiameter = landscape - ? Math.min(172, Math.max(148, height * 0.42)) - : Math.min(156, Math.max(140, width * 0.38)); + const outerPadding = landscape ? 14 : 12; + const controlGap = landscape ? 16 : 18; + const safeWidth = Math.max(0, width - insets.left - insets.right); + const safeHeight = Math.max(0, height - insets.top - insets.bottom); if (landscape) { + const maximumFittingDiameter = Math.max( + 0, + (safeHeight - outerPadding * 2 - controlGap) / 2, + ); + const controlDiameter = Math.min(164, maximumFittingDiameter); + const stackHeight = controlDiameter * 2 + controlGap; + const stackTop = + insets.top + + outerPadding + + Math.max(0, (safeHeight - outerPadding * 2 - stackHeight) / 2); const right = insets.right + outerPadding; + const columnLeft = width - right - controlDiameter; + const hudLeft = insets.left + outerPadding; + const hudWidth = controlPairVisible + ? Math.max(0, Math.min(720, columnLeft - controlGap - hudLeft)) + : Math.max(0, safeWidth - outerPadding * 2); + const hudTop = insets.top + outerPadding; return { outerPadding, - dialDiameter, + controlGap, + controlDiameter, + dialDiameter: controlDiameter, + hudWidth, + hudListMaxHeight: Math.min( + 320, + Math.max(0, height - insets.bottom - outerPadding - hudTop - 82), + ), hudStyle: { position: "absolute", - top: insets.top + outerPadding, - left: insets.left + outerPadding, - width: Math.min(availableWidth * 0.72, 720), - maxHeight: 136, + top: hudTop, + left: hudLeft, + width: hudWidth, + }, + liveStyle: { + position: "absolute", + right, + top: stackTop, + width: controlDiameter, + height: controlDiameter, }, dialStyle: { position: "absolute", right, - top: Math.max( - insets.top + outerPadding, - (height - dialDiameter + insets.top - insets.bottom) / 2, - ), - width: dialDiameter, - height: dialDiameter, + top: stackTop + controlDiameter + controlGap, + width: controlDiameter, + height: controlDiameter, }, }; } + const maximumFittingDiameter = Math.max( + 0, + (safeWidth - outerPadding * 2 - controlGap) / 2, + ); + const controlDiameter = Math.min(156, maximumFittingDiameter); + const pairWidth = controlDiameter * 2 + controlGap; + const pairLeft = insets.left + Math.max(0, (safeWidth - pairWidth) / 2); + const controlsBottom = insets.bottom + outerPadding; + const controlsTop = height - controlsBottom - controlDiameter; + const hudLeft = insets.left + outerPadding; + const hudWidth = Math.max(0, safeWidth - outerPadding * 2); + const hudTop = insets.top + outerPadding; return { outerPadding, - dialDiameter, + controlGap, + controlDiameter, + dialDiameter: controlDiameter, + hudWidth, + hudListMaxHeight: Math.min( + 360, + Math.max(0, controlsTop - controlGap - hudTop - 82), + ), hudStyle: { position: "absolute", - top: insets.top + outerPadding, - left: insets.left + outerPadding, - right: insets.right + outerPadding, - maxHeight: 196, + top: hudTop, + left: hudLeft, + width: hudWidth, + }, + liveStyle: { + position: "absolute", + left: pairLeft, + bottom: controlsBottom, + width: controlDiameter, + height: controlDiameter, }, dialStyle: { position: "absolute", - alignSelf: "center", - left: - insets.left + (width - insets.left - insets.right - dialDiameter) / 2, - bottom: insets.bottom + outerPadding, - width: dialDiameter, - height: dialDiameter, + left: pairLeft + controlDiameter + controlGap, + bottom: controlsBottom, + width: controlDiameter, + height: controlDiameter, }, }; } @@ -80,8 +136,10 @@ interface FieldOverlayLayoutProps { readonly width: number; readonly height: number; readonly landscape: boolean; + readonly controlPairVisible?: boolean; readonly field: React.ReactNode; - readonly hud?: React.ReactNode; + readonly hud?: (metrics: FieldOverlayMetrics) => React.ReactNode; + readonly live?: (diameter: number) => React.ReactNode; readonly dial?: (diameter: number) => React.ReactNode; } @@ -89,12 +147,20 @@ export function FieldOverlayLayout({ width, height, landscape, + controlPairVisible = true, field, hud, + live, dial, }: FieldOverlayLayoutProps) { const insets = useSafeAreaInsets(); - const metrics = getFieldOverlayMetrics({ width, height, landscape, insets }); + const metrics = getFieldOverlayMetrics({ + width, + height, + landscape, + insets, + controlPairVisible, + }); return ( - {hud} + {hud(metrics)} + + ) : null} + {live ? ( + + {live(metrics.controlDiameter)} ) : null} {dial ? ( @@ -117,7 +192,7 @@ export function FieldOverlayLayout({ style={metrics.dialStyle} testID="field-dial-slot" > - {dial(metrics.dialDiameter)} + {dial(metrics.controlDiameter)} ) : null} diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index 602218b6..f5815507 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -10,7 +10,7 @@ import { type FieldLivePositionInput, type FieldPoint, } from "@eight2five/mobile/field"; -import { formatSetName } from "@eight2five/mobile/drill"; +import { formatSetName, getDrillTerms } from "@eight2five/mobile/drill"; import { FIELD_FOUR_STEP_GRID_COLOR, FieldCanvas, @@ -20,9 +20,16 @@ import { useSharedValue, type SharedValue } from "react-native-reanimated"; import { FieldOverlayLayout } from "./field-overlay-layout"; import { useFieldScreenController } from "./use-field-screen-controller"; -import { CoordinatePanel } from "./coordinate-panel/coordinate-panel"; -import { areCoordinatePanelControlsDisabled } from "./coordinate-panel/coordinate-panel-state"; +import { DrillSelectionDialog } from "../drill/components/drill-selection-dialog"; +import { PerformerSelectionDialog } from "../drill/components/performer-selection-dialog"; +import { DrillPill } from "./drill-pill/drill-pill"; +import { + INITIAL_FIELD_HUD_STATE, + reduceFieldHudState, +} from "./field-hud-state"; +import { LiveOnlyPill, LivePositionSquare } from "./live-position-hud"; import { PageDial } from "./page-dial/page-dial"; +import { TagConnectionDialog } from "./tag-connection-dialog"; const EMPTY_ANCHORS: readonly FieldAnchorGeometry[] = Object.freeze([]); @@ -44,6 +51,13 @@ export function FieldScreen({ }) { const theme = useEight2FiveTheme(); const controller = useFieldScreenController(); + const [hudState, dispatchHud] = React.useReducer( + reduceFieldHudState, + INITIAL_FIELD_HUD_STATE, + ); + const [drillDialogOpen, setDrillDialogOpen] = React.useState(false); + const [performerDialogOpen, setPerformerDialogOpen] = React.useState(false); + const [tagDialogOpen, setTagDialogOpen] = React.useState(false); const liveState = livePosition?.state ?? EMPTY_FIELD_LIVE_POSITION_STATE; const fallbackLivePosition = useSharedValue( liveState.position ?? null, @@ -97,6 +111,11 @@ export function FieldScreen({ drillOverlayState, targetPolicy, ); + const controlsDisabled = + controller.settingsStatus !== "ready" || + controller.loadingDrills || + controller.selectionBusy; + const terms = getDrillTerms(controller.settings.drillTerminology); const palette = React.useMemo( () => ({ canvasBackground: theme.background, @@ -114,76 +133,154 @@ export function FieldScreen({ ); return ( - - } - hud={ - - void controller.selectActiveDrill(drillId) - } - onToggleMetric={() => void controller.toggleMetricMode()} - /> - } - dial={ - controller.settings.drillFeaturesEnabled - ? (diameter) => ( - - void controller.selectPageAtIndex(index) - } - /> - ) - : undefined - } - /> + <> + + } + hud={(metrics) => + controller.settings.drillFeaturesEnabled ? ( + + dispatchHud({ type: "toggle-count-display" }) + } + onToggleMetric={() => void controller.toggleMetricMode()} + onToggleExpanded={() => + dispatchHud({ type: "toggle-drill-pill" }) + } + onSelectIndex={(index) => + void controller.selectPageAtIndex(index) + } + /> + ) : ( + setTagDialogOpen(true)} + /> + ) + } + live={ + controller.settings.drillFeaturesEnabled + ? (diameter) => ( + setTagDialogOpen(true)} + /> + ) + : undefined + } + dial={ + controller.settings.drillFeaturesEnabled + ? (diameter) => ( + + void controller.selectPageAtIndex(index) + } + onSelectDrill={() => setDrillDialogOpen(true)} + onSelectPerformer={ + controller.activeDrillDocument + ? () => setPerformerDialogOpen(true) + : undefined + } + /> + ) + : undefined + } + /> + setDrillDialogOpen(false)} + onSelect={(drillId) => { + setDrillDialogOpen(false); + void controller.selectActiveDrill(drillId); + }} + /> + setPerformerDialogOpen(false)} + onConfirm={async (performerEntityId) => { + const saved = await controller.selectPerformer(performerEntityId); + if (saved) setPerformerDialogOpen(false); + }} + /> + setTagDialogOpen(false)} + /> + ); } diff --git a/apps/mobile/src/features/field/live-position-hud-state.ts b/apps/mobile/src/features/field/live-position-hud-state.ts new file mode 100644 index 00000000..6424a8b2 --- /dev/null +++ b/apps/mobile/src/features/field/live-position-hud-state.ts @@ -0,0 +1,63 @@ +import type { FieldPresetId } from "@eight2five/drill-schema"; +import { + fieldPointToMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, + metersToStandardSteps, + type FieldLivePositionState, + type FieldPoint, +} from "@eight2five/mobile/field"; + +import type { CoordinateLines } from "./field-hud-state"; + +export function getLiveCoordinateLines( + live: FieldLivePositionState, + fieldPreset: FieldPresetId = "football-nfhs", +): CoordinateLines | null { + if (!live.position || live.isStale) return null; + const coordinate = fieldPointToMarchingCoordinate(live.position, fieldPreset); + return { + side: formatMarchingSide(coordinate.side), + frontBack: formatMarchingFrontBack(coordinate.frontBack, fieldPreset), + }; +} + +export type DistanceTone = "success" | "warning" | "danger" | "muted"; + +export interface TargetDistancePresentation { + readonly steps?: number; + readonly value: string; + readonly tone: DistanceTone; +} + +export function getTargetDistancePresentation({ + live, + target, + greenThresholdSteps, + yellowThresholdSteps, +}: { + readonly live: FieldLivePositionState; + readonly target?: FieldPoint; + readonly greenThresholdSteps: number; + readonly yellowThresholdSteps: number; +}): TargetDistancePresentation { + if (!live.position || live.isStale || !target) { + return { value: "–", tone: "muted" }; + } + + const distanceMeters = Math.hypot( + live.position.xMeters - target.xMeters, + live.position.yMeters - target.yMeters, + ); + const steps = metersToStandardSteps(distanceMeters); + return { + steps, + value: `${steps.toFixed(1)} steps`, + tone: + steps <= greenThresholdSteps + ? "success" + : steps <= yellowThresholdSteps + ? "warning" + : "danger", + }; +} diff --git a/apps/mobile/src/features/field/live-position-hud.tsx b/apps/mobile/src/features/field/live-position-hud.tsx new file mode 100644 index 00000000..e9bc5f12 --- /dev/null +++ b/apps/mobile/src/features/field/live-position-hud.tsx @@ -0,0 +1,311 @@ +import React from "react"; +import { Animated, Easing } from "react-native"; +import { + BluetoothConnected, + BluetoothOff, + LoaderCircle, + RulerDimensionLine, + TriangleAlert, +} from "lucide-react-native"; +import type { + FieldConnectionState, + FieldLivePositionState, + FieldPoint, +} from "@eight2five/mobile/field"; +import type { FieldPresetId } from "@eight2five/drill-schema"; +import { Divider } from "@eight2five/ui/components/divider"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { + getLiveCoordinateLines, + getTargetDistancePresentation, + type DistanceTone, +} from "./live-position-hud-state"; + +export function LivePositionSquare({ + diameter, + live, + target, + fieldPreset, + greenThresholdSteps, + yellowThresholdSteps, + onOpenTagConnection, +}: { + readonly diameter: number; + readonly live: FieldLivePositionState; + readonly target?: FieldPoint; + readonly fieldPreset: FieldPresetId; + readonly greenThresholdSteps: number; + readonly yellowThresholdSteps: number; + readonly onOpenTagConnection: () => void; +}) { + const theme = useEight2FiveTheme(); + const distance = getTargetDistancePresentation({ + live, + target, + greenThresholdSteps, + yellowThresholdSteps, + }); + const distanceColor = colorForDistanceTone(distance.tone, theme); + + return ( + + + + + + + {distance.value} + + + + ); +} + +export function LiveOnlyPill({ + width, + live, + fieldPreset, + onOpenTagConnection, +}: { + readonly width: number; + readonly live: FieldLivePositionState; + readonly fieldPreset: FieldPresetId; + readonly onOpenTagConnection: () => void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + ); +} + +function LivePositionHeader({ + live, + fieldPreset, + compact = false, + onOpenTagConnection, +}: { + readonly live: FieldLivePositionState; + readonly fieldPreset: FieldPresetId; + readonly compact?: boolean; + readonly onOpenTagConnection: () => void; +}) { + const theme = useEight2FiveTheme(); + const coordinate = getLiveCoordinateLines(live, fieldPreset); + return ( + + + + {coordinate ? `${coordinate.side}\n${coordinate.frontBack}` : "–"} + + + ); +} + +function BluetoothStatusButton({ + state, + onPress, +}: { + readonly state: FieldConnectionState; + readonly onPress: () => void; +}) { + const theme = useEight2FiveTheme(); + const [spin] = React.useState(() => new Animated.Value(0)); + const animated = state === "connecting" || state === "reconnecting"; + React.useEffect(() => { + if (!animated) { + spin.stopAnimation(); + spin.setValue(0); + return; + } + const animation = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + animation.start(); + return () => animation.stop(); + }, [animated, spin]); + + const presentation = getConnectionIconPresentation(state); + const color = + presentation.tone === "success" + ? theme.success + : presentation.tone === "accent" + ? theme.accent + : presentation.tone === "danger" + ? theme.danger + : theme.textMuted; + const icon = ; + + return ( + + {animated ? ( + + {icon} + + ) : ( + icon + )} + + ); +} + +function getConnectionIconPresentation(state: FieldConnectionState) { + switch (state) { + case "connected": + return { + label: "Connected", + icon: BluetoothConnected, + tone: "success" as const, + }; + case "connecting": + case "reconnecting": + return { + label: state === "connecting" ? "Connecting" : "Reconnecting", + icon: LoaderCircle, + tone: "accent" as const, + }; + case "error": + return { + label: "Connection error", + icon: TriangleAlert, + tone: "danger" as const, + }; + case "idle": + case "disconnected": + return { + label: "Disconnected", + icon: BluetoothOff, + tone: "muted" as const, + }; + } +} + +function colorForDistanceTone( + tone: DistanceTone, + theme: ReturnType, +): string { + switch (tone) { + case "success": + return theme.success; + case "warning": + return theme.warning; + case "danger": + return theme.danger; + case "muted": + return theme.textMuted; + } +} diff --git a/apps/mobile/src/features/field/tag-connection-dialog.tsx b/apps/mobile/src/features/field/tag-connection-dialog.tsx new file mode 100644 index 00000000..fa966be9 --- /dev/null +++ b/apps/mobile/src/features/field/tag-connection-dialog.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { useWindowDimensions } from "react-native"; +import { X } from "lucide-react-native"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Icon } from "@eight2five/ui/components/icon"; +import { + Modal, + ModalBackdrop, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, +} from "@eight2five/ui/components/modal"; + +import { TagConnectionContent } from "../settings/tag-connection-screen"; + +export function TagConnectionDialog({ + isOpen, + onClose, +}: { + readonly isOpen: boolean; + readonly onClose: () => void; +}) { + const { height } = useWindowDimensions(); + if (!isOpen) return null; + return ( + + + + + Tag Connection + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts index 46bd8c13..6e27d6f5 100644 --- a/apps/mobile/src/features/field/use-field-screen-controller.ts +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -33,6 +33,9 @@ export function useFieldScreenController() { const store = useAppSettingsStore(); const [initialViewport] = React.useState(() => committedFieldViewport); const [drills, setDrills] = React.useState([]); + const [drillEntries, setDrillEntries] = React.useState< + readonly { readonly drill: Drill; readonly pageCount: number }[] + >([]); const [activeDrill, setActiveDrill] = React.useState(); const [activeDrillDocument, setActiveDrillDocument] = React.useState(); @@ -57,17 +60,24 @@ export function useFieldScreenController() { try { const repository = store.getDrillRepository(); const activeDrillId = snapshot.settings.activeDrillId; - const [nextDrills, nextActiveDrill, nextPages, nextDocument] = + const nextDrills = await repository.listDrills(); + const [nextActiveDrill, nextPages, nextDocument, pageCounts] = await Promise.all([ - repository.listDrills(), activeDrillId ? repository.getDrill(activeDrillId) : undefined, activeDrillId ? repository.listSets(activeDrillId) : [], activeDrillId ? repository.getDrillDocument(activeDrillId) : undefined, + Promise.all( + nextDrills.map(async (drill) => ({ + drill, + pageCount: (await repository.listSets(drill.id)).length, + })), + ), ]); if (generation !== refreshGeneration.current) return; setDrills(nextDrills); + setDrillEntries(pageCounts); setActiveDrill(nextActiveDrill); setActiveDrillDocument(nextDocument); setPages(nextPages); @@ -145,6 +155,31 @@ export function useFieldScreenController() { [pages, snapshot.settings.activeDrillId, snapshot.status, store], ); + const selectPerformer = React.useCallback( + async (performerEntityId: number) => { + if (!activeDrill || selectionBusy || snapshot.status !== "ready") { + return false; + } + setSelectionBusy(true); + setFieldError(undefined); + try { + await store + .getDrillRepository() + .setSelectedPerformer(activeDrill.id, performerEntityId); + await refreshDrills(); + return true; + } catch (cause) { + setFieldError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + return false; + } finally { + setSelectionBusy(false); + } + }, + [activeDrill, refreshDrills, selectionBusy, snapshot.status, store], + ); + const effectiveSelectedPageId = optimisticSelection?.activeDrillId === snapshot.settings.activeDrillId ? optimisticSelection.pageId @@ -209,6 +244,7 @@ export function useFieldScreenController() { settingsStatus: snapshot.status, settings: snapshot.settings, drills, + drillEntries, activeDrill, activeDrillDocument, drillScene, @@ -223,6 +259,7 @@ export function useFieldScreenController() { selectActiveDrill, toggleMetricMode, selectPageAtIndex, + selectPerformer, refreshDrills, } as const; } diff --git a/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts b/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts index a8f05560..2bb33559 100644 --- a/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts +++ b/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts @@ -18,7 +18,9 @@ describe("Tag Connection discovery lifecycle", () => { startTagDiscovery: jest.fn(async () => undefined), stopManualDiscovery: jest.fn(), }; - ownTagDiscoveryWhileFocused(store, true, true, jest.fn()); + const cleanup = ownTagDiscoveryWhileFocused(store, true, true, jest.fn()); expect(store.startTagDiscovery).not.toHaveBeenCalled(); + cleanup(); + expect(store.stopManualDiscovery).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/src/features/settings/tag-connection-lifecycle.ts b/apps/mobile/src/features/settings/tag-connection-lifecycle.ts index 03e08673..6bfb1393 100644 --- a/apps/mobile/src/features/settings/tag-connection-lifecycle.ts +++ b/apps/mobile/src/features/settings/tag-connection-lifecycle.ts @@ -9,12 +9,11 @@ export function ownTagDiscoveryWhileFocused( alreadyConnected: boolean, onError: (error: Error) => void, ): () => void { - if (servicesReady && !alreadyConnected) { - void store - .startTagDiscovery() - .catch((cause) => - onError(cause instanceof Error ? cause : new Error(String(cause))), - ); - } + if (!servicesReady || alreadyConnected) return () => undefined; + void store + .startTagDiscovery() + .catch((cause) => + onError(cause instanceof Error ? cause : new Error(String(cause))), + ); return () => store.stopManualDiscovery(); } diff --git a/apps/mobile/src/features/settings/tag-connection-screen.tsx b/apps/mobile/src/features/settings/tag-connection-screen.tsx index 50d0e68d..f4a3ed29 100644 --- a/apps/mobile/src/features/settings/tag-connection-screen.tsx +++ b/apps/mobile/src/features/settings/tag-connection-screen.tsx @@ -20,6 +20,7 @@ import { HStack } from "@eight2five/ui/components/hstack"; import { Icon } from "@eight2five/ui/components/icon"; import { Input, InputField } from "@eight2five/ui/components/input"; import { Pressable } from "@eight2five/ui/components/pressable"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; @@ -57,7 +58,11 @@ export function TagConnectionScreen() { } /** Shared route/modal body. Discovery ownership follows focus lifecycle. */ -export function TagConnectionContent() { +export function TagConnectionContent({ + modal = false, +}: { + readonly modal?: boolean; +}) { const router = useRouter(); const theme = useEight2FiveTheme(); const store = useMobilePansStore(); @@ -93,7 +98,7 @@ export function TagConnectionContent() { return ownTagDiscoveryWhileFocused( store, snapshot.initialization === "ready", - false, + store.getSnapshot().connectionState === "connected", setError, ); }, [snapshot.initialization, store]), @@ -112,8 +117,8 @@ export function TagConnectionContent() { } }; - return ( - + const content = ( + <> {snapshot.error || error ? ( {(error ?? snapshot.error)?.message} @@ -246,12 +251,14 @@ export function TagConnectionContent() { disabled={operation} testID="active-network-setting" /> - router.push("/(tabs)/settings/networks" as never)} - testID="network-management-link" - /> + {!modal ? ( + router.push("/(tabs)/settings/networks" as never)} + testID="network-management-link" + /> + ) : null} {snapshot.rememberedTag ? ( @@ -296,7 +303,23 @@ export function TagConnectionContent() { ) : null} - + + ); + + if (!modal) + return {content}; + return ( + + {content} + ); } From 5f395792b2120d780a853053499929da9658669f Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 18:41:09 -0500 Subject: [PATCH 076/101] fix(mobile): polish field mvp interactions --- .../src/features/settings/settings-screen.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 03f18892..6840d7d5 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -12,6 +12,7 @@ import { Radio, Route, Rows3, + RulerDimensionLine, } from "lucide-react-native"; import type { AppearanceMode, @@ -76,6 +77,15 @@ const TRANSITION_COUNT_CHOICES = Array.from({ length: 51 }, (_, count) => ({ value: String(count), })); +const DISTANCE_THRESHOLD_VALUES = [0, 0.25, 0.5, 0.75, 1, 1.5, 2, 3]; + +function distanceThresholdChoices(values: readonly number[]) { + return values.map((value) => ({ + label: `${value} steps`, + value: String(value), + })); +} + export function SettingsScreen() { const router = useRouter(); const store = useAppSettingsStore(); @@ -221,6 +231,48 @@ export function SettingsScreen() { disabled={disabled} testID="transition-metric-setting" /> + + icon={RulerDimensionLine} + title="Green distance threshold" + description="Show target distance as green at or below this value." + value={String(settings.distanceGreenThresholdSteps)} + choices={distanceThresholdChoices( + Array.from( + new Set([ + ...DISTANCE_THRESHOLD_VALUES.filter( + (value) => value <= settings.distanceYellowThresholdSteps, + ), + settings.distanceGreenThresholdSteps, + ]), + ).sort((left, right) => left - right), + )} + onChange={(value) => + void update({ distanceGreenThresholdSteps: Number(value) }) + } + disabled={disabled} + testID="distance-green-threshold-setting" + /> + + icon={RulerDimensionLine} + title="Yellow distance threshold" + description="Show target distance as yellow through this value, then red." + value={String(settings.distanceYellowThresholdSteps)} + choices={distanceThresholdChoices( + Array.from( + new Set([ + ...DISTANCE_THRESHOLD_VALUES.filter( + (value) => value >= settings.distanceGreenThresholdSteps, + ), + settings.distanceYellowThresholdSteps, + ]), + ).sort((left, right) => left - right), + )} + onChange={(value) => + void update({ distanceYellowThresholdSteps: Number(value) }) + } + disabled={disabled} + testID="distance-yellow-threshold-setting" + /> Date: Wed, 5 Aug 2026 19:03:31 -0500 Subject: [PATCH 077/101] fix(drill-converter): include UniWind type declarations --- apps/drill-converter/tsconfig.json | 3 ++- apps/drill-converter/uniwind-types.d.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 apps/drill-converter/uniwind-types.d.ts diff --git a/apps/drill-converter/tsconfig.json b/apps/drill-converter/tsconfig.json index 8e2d458a..ede53e2c 100644 --- a/apps/drill-converter/tsconfig.json +++ b/apps/drill-converter/tsconfig.json @@ -9,6 +9,7 @@ "src/**/*.ts", "src/**/*.tsx", ".expo/types/**/*.ts", - "expo-env.d.ts" + "expo-env.d.ts", + "uniwind-types.d.ts" ] } diff --git a/apps/drill-converter/uniwind-types.d.ts b/apps/drill-converter/uniwind-types.d.ts new file mode 100644 index 00000000..2cc862f1 --- /dev/null +++ b/apps/drill-converter/uniwind-types.d.ts @@ -0,0 +1 @@ +/// From aa4696959db940cd364e5670607d1c60fdf35ff8 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 20:51:21 -0500 Subject: [PATCH 078/101] refactor(mobile): remove Info tab Remove the route, feature implementation, tests, and unused Git SHA metadata now that the tab is no longer part of mobile navigation. --- apps/mobile/app.config.ts | 22 --- apps/mobile/app/(tabs)/info/_layout.tsx | 23 --- apps/mobile/app/(tabs)/info/index.tsx | 5 - .../info/__tests__/info-metadata.test.ts | 69 ------- .../info/__tests__/info-theme-assets.test.ts | 30 --- .../mobile/src/features/info/info-metadata.ts | 73 -------- apps/mobile/src/features/info/info-screen.tsx | 175 ------------------ .../src/features/info/info-theme-assets.ts | 20 -- .../navigation/__tests__/mobile-tabs.test.ts | 14 -- apps/mobile/src/navigation/mobile-tabs.ts | 10 +- 10 files changed, 1 insertion(+), 440 deletions(-) delete mode 100644 apps/mobile/app/(tabs)/info/_layout.tsx delete mode 100644 apps/mobile/app/(tabs)/info/index.tsx delete mode 100644 apps/mobile/src/features/info/__tests__/info-metadata.test.ts delete mode 100644 apps/mobile/src/features/info/__tests__/info-theme-assets.test.ts delete mode 100644 apps/mobile/src/features/info/info-metadata.ts delete mode 100644 apps/mobile/src/features/info/info-screen.tsx delete mode 100644 apps/mobile/src/features/info/info-theme-assets.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index a044af1e..51bd74ba 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,31 +1,10 @@ -import { execFileSync } from "node:child_process"; import type { ExpoConfig } from "expo/config"; -function resolveGitSha(): string { - const injectedSha = ( - process.env.EIGHT2FIVE_GIT_SHA ?? - process.env.EAS_BUILD_GIT_COMMIT_HASH ?? - process.env.GITHUB_SHA - )?.trim(); - if (injectedSha) return injectedSha; - - try { - return execFileSync("git", ["rev-parse", "--short", "HEAD"], { - cwd: __dirname, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - return "unknown"; - } -} - const buildId = process.env.E2F_BUILD_ID ?? process.env.EAS_BUILD_GIT_COMMIT_HASH ?? process.env.GITHUB_SHA ?? "local"; -const gitSha = resolveGitSha(); const requestedVersionCode = Number( process.env.E2F_ANDROID_VERSION_CODE ?? process.env.GITHUB_RUN_NUMBER ?? 1, ); @@ -150,7 +129,6 @@ const config: ExpoConfig = { }, extra: { buildId, - EIGHT2FIVE_GIT_SHA: gitSha, eas: { projectId: "a26bddc3-6439-460b-b15b-51143e499c8a", }, diff --git a/apps/mobile/app/(tabs)/info/_layout.tsx b/apps/mobile/app/(tabs)/info/_layout.tsx deleted file mode 100644 index a828cdfa..00000000 --- a/apps/mobile/app/(tabs)/info/_layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Stack } from "expo-router"; -import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; - -export default function InfoLayout() { - const theme = useEight2FiveTheme(); - - return ( - - - - ); -} diff --git a/apps/mobile/app/(tabs)/info/index.tsx b/apps/mobile/app/(tabs)/info/index.tsx deleted file mode 100644 index 81eaa151..00000000 --- a/apps/mobile/app/(tabs)/info/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { InfoScreen } from "../../../src/features/info/info-screen"; - -export default function InfoRoute() { - return ; -} diff --git a/apps/mobile/src/features/info/__tests__/info-metadata.test.ts b/apps/mobile/src/features/info/__tests__/info-metadata.test.ts deleted file mode 100644 index 402fac1d..00000000 --- a/apps/mobile/src/features/info/__tests__/info-metadata.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - EIGHT2FIVE_APP_NAME, - EIGHT2FIVE_APP_VERSION, - EIGHT2FIVE_GITHUB_URL, - EIGHT2FIVE_LICENSE_URL, - INFO_UNAVAILABLE, - getMobileInfoMetadata, - getShortGitSha, -} from "../info-metadata"; - -describe("mobile info metadata", () => { - test("uses the iOS build number and shortens the injected SHA", () => { - expect( - getMobileInfoMetadata( - { - name: EIGHT2FIVE_APP_NAME, - version: EIGHT2FIVE_APP_VERSION, - ios: { buildNumber: "42" }, - android: { versionCode: 7 }, - extra: { - EIGHT2FIVE_GIT_SHA: "0123456789abcdef0123456789abcdef01234567", - }, - }, - "ios", - ), - ).toEqual({ - appName: EIGHT2FIVE_APP_NAME, - version: EIGHT2FIVE_APP_VERSION, - nativeBuildLabel: "iOS build number", - nativeBuildValue: "42", - gitSha: "0123456", - }); - }); - - test("uses the Android version code and exposes the external targets", () => { - const metadata = getMobileInfoMetadata( - { - android: { versionCode: 7 }, - extra: { EIGHT2FIVE_GIT_SHA: "abcdef1" }, - }, - "android", - ); - - expect(metadata.nativeBuildLabel).toBe("Android version code"); - expect(metadata.nativeBuildValue).toBe("7"); - expect(metadata.gitSha).toBe("abcdef1"); - expect(EIGHT2FIVE_LICENSE_URL).toBe( - `${EIGHT2FIVE_GITHUB_URL}/blob/main/LICENSE`, - ); - }); - - test("prefers the build identifier reported by the installed native app", () => { - expect( - getMobileInfoMetadata({ ios: { buildNumber: "1" } }, "ios", "84") - .nativeBuildValue, - ).toBe("84"); - }); - - test("does not fabricate missing or invalid build metadata", () => { - expect(getMobileInfoMetadata(undefined, "ios")).toMatchObject({ - appName: EIGHT2FIVE_APP_NAME, - version: EIGHT2FIVE_APP_VERSION, - nativeBuildValue: INFO_UNAVAILABLE, - gitSha: INFO_UNAVAILABLE, - }); - expect(getShortGitSha("local")).toBe(INFO_UNAVAILABLE); - expect(getShortGitSha("not-a-sha")).toBe(INFO_UNAVAILABLE); - }); -}); diff --git a/apps/mobile/src/features/info/__tests__/info-theme-assets.test.ts b/apps/mobile/src/features/info/__tests__/info-theme-assets.test.ts deleted file mode 100644 index 7bbbcdca..00000000 --- a/apps/mobile/src/features/info/__tests__/info-theme-assets.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - INFO_SPLASH_ASSET_PATHS, - getInfoSplashAssetPath, -} from "../info-theme-assets"; - -describe("mobile info splash assets", () => { - test("selects the canonical light and dark asset for each native platform", () => { - expect(getInfoSplashAssetPath("light", "ios")).toBe( - "./assets/splash-icons/mobile-ios-splash-icon-light.png", - ); - expect(getInfoSplashAssetPath("dark", "ios")).toBe( - "./assets/splash-icons/mobile-ios-splash-icon-dark.png", - ); - expect(getInfoSplashAssetPath("light", "android")).toBe( - "./assets/splash-icons/mobile-ios-splash-icon-light.png", - ); - expect(getInfoSplashAssetPath("dark", "android")).toBe( - "./assets/splash-icons/mobile-ios-splash-icon-dark.png", - ); - }); - - test("keeps light and dark assets distinct on both platforms", () => { - expect(INFO_SPLASH_ASSET_PATHS.light.ios).not.toBe( - INFO_SPLASH_ASSET_PATHS.dark.ios, - ); - expect(INFO_SPLASH_ASSET_PATHS.light.android).not.toBe( - INFO_SPLASH_ASSET_PATHS.dark.android, - ); - }); -}); diff --git a/apps/mobile/src/features/info/info-metadata.ts b/apps/mobile/src/features/info/info-metadata.ts deleted file mode 100644 index 38ed3f96..00000000 --- a/apps/mobile/src/features/info/info-metadata.ts +++ /dev/null @@ -1,73 +0,0 @@ -export const EIGHT2FIVE_APP_NAME = "Eight2Five"; -export const EIGHT2FIVE_APP_VERSION = "0.1.0"; -export const EIGHT2FIVE_GITHUB_URL = "https://github.com/CDGuth/Eight2Five"; -export const EIGHT2FIVE_LICENSE_URL = `${EIGHT2FIVE_GITHUB_URL}/blob/main/LICENSE`; -export const INFO_UNAVAILABLE = "Unavailable"; - -export interface InfoExpoConfig { - name?: string; - version?: string; - ios?: { - buildNumber?: string | number; - }; - android?: { - versionCode?: number | string; - }; - extra?: { - EIGHT2FIVE_GIT_SHA?: unknown; - }; -} - -export interface MobileInfoMetadata { - appName: string; - version: string; - nativeBuildLabel: string; - nativeBuildValue: string; - gitSha: string; -} - -export function getMobileInfoMetadata( - config: InfoExpoConfig | null | undefined, - platform: string, - nativeBuildVersion?: string | null, -): MobileInfoMetadata { - const nativeBuildLabel = getNativeBuildLabel(platform); - const nativeBuildValue = - nativeBuildVersion?.trim() || getNativeBuildValue(config, platform); - - return { - appName: EIGHT2FIVE_APP_NAME, - version: config?.version?.trim() || EIGHT2FIVE_APP_VERSION, - nativeBuildLabel, - nativeBuildValue, - gitSha: getShortGitSha(config?.extra?.EIGHT2FIVE_GIT_SHA), - }; -} - -export function getNativeBuildLabel(platform: string): string { - if (platform === "ios") return "iOS build number"; - if (platform === "android") return "Android version code"; - return "Native build"; -} - -export function getShortGitSha(value: unknown): string { - if (typeof value !== "string") return INFO_UNAVAILABLE; - - const normalized = value.trim(); - if (!/^[0-9a-f]{7,40}$/i.test(normalized)) return INFO_UNAVAILABLE; - return normalized.slice(0, 7); -} - -function getNativeBuildValue( - config: InfoExpoConfig | null | undefined, - platform: string, -): string { - const value = - platform === "ios" - ? config?.ios?.buildNumber - : platform === "android" - ? config?.android?.versionCode - : undefined; - - return value == null ? INFO_UNAVAILABLE : String(value); -} diff --git a/apps/mobile/src/features/info/info-screen.tsx b/apps/mobile/src/features/info/info-screen.tsx deleted file mode 100644 index a4c09374..00000000 --- a/apps/mobile/src/features/info/info-screen.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React from "react"; -import Constants from "expo-constants"; -import * as Application from "expo-application"; -import { Linking, Platform } from "react-native"; -import { Card } from "@eight2five/ui/components/card"; -import { Divider } from "@eight2five/ui/components/divider"; -import { HStack } from "@eight2five/ui/components/hstack"; -import { Image } from "@eight2five/ui/components/image"; -import { Pressable } from "@eight2five/ui/components/pressable"; -import { ScrollView } from "@eight2five/ui/components/scroll-view"; -import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; -import { - eight2FiveFonts, - eight2FiveRadii, - eight2FiveSpacing, - useEight2FiveTheme, - useEight2FiveThemeName, -} from "@eight2five/ui/theme"; - -import { - EIGHT2FIVE_GITHUB_URL, - EIGHT2FIVE_LICENSE_URL, - getMobileInfoMetadata, -} from "./info-metadata"; - -const INFO_SPLASH_ASSET_SOURCES = { - ios: { - light: require("../../../assets/splash-icons/mobile-ios-splash-icon-light.png"), - dark: require("../../../assets/splash-icons/mobile-ios-splash-icon-dark.png"), - }, - android: { - light: require("../../../assets/splash-icons/mobile-ios-splash-icon-light.png"), - dark: require("../../../assets/splash-icons/mobile-ios-splash-icon-dark.png"), - }, -} as const; - -export function InfoScreen() { - const theme = useEight2FiveTheme(); - const themeName = useEight2FiveThemeName(); - const metadata = getMobileInfoMetadata( - Constants.expoConfig, - Platform.OS, - Application.nativeBuildVersion, - ); - const platform = Platform.OS === "android" ? "android" : "ios"; - const splashSource = INFO_SPLASH_ASSET_SOURCES[platform][themeName]; - - return ( - - - - - {metadata.appName} - - - - - - - - - - - - - - - - - ); -} - -function InfoRow({ label, value }: { label: string; value: string }) { - const theme = useEight2FiveTheme(); - - return ( - - - {label} - - - {value} - - - ); -} - -function ExternalLink({ - label, - testID, - url, -}: { - label: string; - testID: string; - url: string; -}) { - const theme = useEight2FiveTheme(); - - return ( - void Linking.openURL(url)} - style={{ padding: eight2FiveSpacing.xs }} - > - - {label} - - - ); -} diff --git a/apps/mobile/src/features/info/info-theme-assets.ts b/apps/mobile/src/features/info/info-theme-assets.ts deleted file mode 100644 index a59b9040..00000000 --- a/apps/mobile/src/features/info/info-theme-assets.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const INFO_SPLASH_ASSET_PATHS = { - light: { - ios: "./assets/splash-icons/mobile-ios-splash-icon-light.png", - android: "./assets/splash-icons/mobile-ios-splash-icon-light.png", - }, - dark: { - ios: "./assets/splash-icons/mobile-ios-splash-icon-dark.png", - android: "./assets/splash-icons/mobile-ios-splash-icon-dark.png", - }, -} as const; - -export type InfoThemeName = keyof typeof INFO_SPLASH_ASSET_PATHS; -export type InfoSplashPlatform = "ios" | "android"; - -export function getInfoSplashAssetPath( - themeName: InfoThemeName, - platform: InfoSplashPlatform, -): string { - return INFO_SPLASH_ASSET_PATHS[themeName][platform]; -} diff --git a/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts index 4d2f9bd0..2db39ec0 100644 --- a/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts +++ b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts @@ -11,23 +11,9 @@ describe("mobile native tab navigation", () => { { name: "field", label: "Field" }, { name: "drill", label: "Drill" }, { name: "settings", label: "Settings" }, - { name: "info", label: "Info" }, ]); }); - test("uses native info icons and keeps Info available", () => { - const infoTab = MOBILE_TABS.find(({ name }) => name === "info"); - - expect(infoTab).toEqual({ - name: "info", - label: "Info", - icon: { - sf: { default: "info.circle", selected: "info.circle.fill" }, - md: "info", - }, - }); - }); - test("hides the entire tab bar only for focused landscape Field", () => { expect( shouldHideNativeTabBar({ fieldFocused: true, fieldLandscape: true }), diff --git a/apps/mobile/src/navigation/mobile-tabs.ts b/apps/mobile/src/navigation/mobile-tabs.ts index e5c3a4de..a8500b57 100644 --- a/apps/mobile/src/navigation/mobile-tabs.ts +++ b/apps/mobile/src/navigation/mobile-tabs.ts @@ -1,6 +1,6 @@ import type { NativeTabsTriggerIconProps } from "expo-router/unstable-native-tabs"; -export type MobileTabName = "field" | "drill" | "settings" | "info"; +export type MobileTabName = "field" | "drill" | "settings"; export interface MobileTabConfig { name: MobileTabName; @@ -36,14 +36,6 @@ export const MOBILE_TABS = [ md: "settings", }, }, - { - name: "info", - label: "Info", - icon: { - sf: { default: "info.circle", selected: "info.circle.fill" }, - md: "info", - }, - }, ] as const satisfies readonly MobileTabConfig[]; export function shouldHideNativeTabBar({ From b3caea39281b02120c4f6a43fa7c90c221ed1b05 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 20:52:23 -0500 Subject: [PATCH 079/101] fix(ui): add alt text to message images Provide descriptive alternative text for file previews so image components satisfy accessibility requirements without emitting missing-alt warnings. --- packages/ui/components/chat-ai/message.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/components/chat-ai/message.tsx b/packages/ui/components/chat-ai/message.tsx index 34dc9386..30c2d1da 100644 --- a/packages/ui/components/chat-ai/message.tsx +++ b/packages/ui/components/chat-ai/message.tsx @@ -247,6 +247,7 @@ export const MessageResponse = memo(({ message }: { message: UIMessage }) => { Message attachment From fe7974acd08beff2c8928b311aa2069419a2414e Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 21:25:35 -0500 Subject: [PATCH 080/101] fix(storage): Repair mobile database recovery Move the cross-column settings constraint after all column declarations so the current SQLite schema parses correctly. Add a destructive developer recovery action that closes, deletes, and recreates only the disposable app database when schema changes leave local storage unusable. --- .../settings/developer-settings-screen.tsx | 84 +++++++++++++++++++ .../__tests__/app-settings-store.test.ts | 35 ++++++++ apps/mobile/src/state/app-settings-store.tsx | 32 +++++++ packages/mobile/src/mobile-repositories.ts | 13 +++ .../storage/__tests__/mobileDatabase.test.ts | 9 +- packages/mobile/src/storage/mobileDatabase.ts | 10 +-- 6 files changed, 177 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx index dc3a540d..38c591d3 100644 --- a/apps/mobile/src/features/settings/developer-settings-screen.tsx +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Alert } from "react-native"; import { useRouter } from "expo-router"; import { Activity, @@ -57,6 +58,7 @@ export function DeveloperSettingsScreen() { const { status, settings, error: settingsError } = useAppSettingsSnapshot(); const pans = useMobilePansSnapshot(); const [refreshing, setRefreshing] = React.useState(false); + const [rebuildingDatabase, setRebuildingDatabase] = React.useState(false); const [operationError, setOperationError] = React.useState(); const [rangeDraft, setRangeDraft] = React.useState(() => settings.comfortableAnchorRangeMeters.toString(), @@ -92,6 +94,36 @@ export function DeveloperSettingsScreen() { } }; + const rebuildDatabase = async () => { + if (rebuildingDatabase) return; + setRebuildingDatabase(true); + setOperationError(undefined); + try { + await settingsStore.rebuildDatabase(); + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } finally { + setRebuildingDatabase(false); + } + }; + + const confirmDatabaseRebuild = () => { + Alert.alert( + "Rebuild app database?", + "This deletes all locally stored drills and app settings, then recreates the app database from the current schema. PANS device and network data is not deleted.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Rebuild", + style: "destructive", + onPress: () => void rebuildDatabase(), + }, + ], + ); + }; + const updateOverlay = async (partial: { showCachedAnchorGeometry?: boolean; showComfortableAnchorRange?: boolean; @@ -116,6 +148,11 @@ export function DeveloperSettingsScreen() { if (!settings.developerModeEnabled) { return ( + {settingsError || operationError ? ( + + {(operationError ?? settingsError)?.message} + + ) : null} + {status === "error" ? ( + + + + If the app database cannot open after a schema change, rebuild + it from the current schema. This clears local drills and app + settings but leaves PANS device and network data alone. + + + + + ) : null} ); } @@ -247,6 +308,29 @@ export function DeveloperSettingsScreen() { /> + + + + Delete the disposable app SQLite database and recreate it from the + current schema. This clears local drills and app settings but does + not delete PANS device or network data. + + + + + { ); }); + test("can rebuild the app database after an initialization failure", async () => { + const close = jest.fn(async () => undefined); + const storage = { + settingsRepository: { + load: jest.fn(async () => DEFAULT_APP_SETTINGS), + update: jest.fn(), + resetPreferences: jest.fn(), + }, + drillRepository: {}, + close, + } as unknown as OpenMobileRepositoriesResult; + const openStorage = jest + .fn, []>() + .mockRejectedValueOnce(new Error("schema failed")) + .mockResolvedValueOnce(storage); + const deleteStorage = jest.fn(async () => undefined); + const store = new AppSettingsStore(openStorage, deleteStorage); + + await store.initialize(); + expect(store.getSnapshot().status).toBe("error"); + + const settings = await store.rebuildDatabase(); + + expect(deleteStorage).toHaveBeenCalledTimes(1); + expect(openStorage).toHaveBeenCalledTimes(2); + expect(settings).toEqual(DEFAULT_APP_SETTINGS); + expect(store.getSnapshot()).toEqual({ + status: "ready", + settings: DEFAULT_APP_SETTINGS, + }); + + await store.dispose(); + expect(close).toHaveBeenCalledTimes(1); + }); + test("waits for queued writes before closing storage", async () => { let releaseUpdate!: () => void; const close = jest.fn(async () => undefined); diff --git a/apps/mobile/src/state/app-settings-store.tsx b/apps/mobile/src/state/app-settings-store.tsx index 005d2c1b..977eb3d6 100644 --- a/apps/mobile/src/state/app-settings-store.tsx +++ b/apps/mobile/src/state/app-settings-store.tsx @@ -5,6 +5,7 @@ import { type AppSettingsUpdate, } from "@eight2five/mobile/settings"; import { + deleteMobileDatabase, openMobileRepositories, type OpenMobileRepositoriesResult, } from "@eight2five/mobile/storage"; @@ -19,6 +20,7 @@ export interface AppSettingsStoreSnapshot { export type OpenAppSettingsStorage = () => Promise; +export type DeleteAppSettingsStorage = () => Promise; const INITIAL_SNAPSHOT: AppSettingsStoreSnapshot = Object.freeze({ status: "loading", @@ -36,6 +38,8 @@ export class AppSettingsStore { constructor( private readonly openStorage: OpenAppSettingsStorage = () => openMobileRepositories(), + private readonly deleteStorage: DeleteAppSettingsStorage = () => + deleteMobileDatabase(), ) {} readonly getSnapshot = (): AppSettingsStoreSnapshot => this.snapshot; @@ -128,6 +132,34 @@ export class AppSettingsStore { }); } + /** + * Destructively rebuild the disposable app database from the current schema. + * This intentionally does not touch the separate PANS manager database. + */ + async rebuildDatabase(): Promise { + this.publish(INITIAL_SNAPSHOT); + try { + await this.dispose(); + await this.deleteStorage(); + await this.initialize(); + const snapshot = this.snapshot; + if (snapshot.status !== "ready") { + throw snapshot.error ?? new Error("App database rebuild failed."); + } + return snapshot.settings; + } catch (cause) { + const error = toError(cause); + this.publish( + Object.freeze({ + status: "error", + settings: DEFAULT_APP_SETTINGS, + error, + }), + ); + throw error; + } + } + getDrillRepository() { return this.requireStorage().drillRepository; } diff --git a/packages/mobile/src/mobile-repositories.ts b/packages/mobile/src/mobile-repositories.ts index 756069b8..3da20ade 100644 --- a/packages/mobile/src/mobile-repositories.ts +++ b/packages/mobile/src/mobile-repositories.ts @@ -12,6 +12,19 @@ export interface OpenMobileRepositoriesResult { close(): Promise; } +/** + * Delete the disposable app-side SQLite database so it can be recreated from + * the current schema on the next open. Callers must close every connection to + * this database before invoking this helper. The separate PANS manager database + * is intentionally unaffected. + */ +export async function deleteMobileDatabase( + databaseName = MOBILE_DB_NAME, +): Promise { + const { deleteDatabaseAsync } = await import("expo-sqlite"); + await deleteDatabaseAsync(databaseName); +} + /** * Open the app-side repositories over one database connection. * diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index 8a366367..7903dada 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -15,7 +15,7 @@ describe("mobile app SQLite schema preparation", () => { const sql = executed.join("\n"); expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); - expect(MOBILE_SCHEMA_VERSION).toBe(5); + expect(MOBILE_SCHEMA_VERSION).toBe(6); expect(sql).toContain("PRAGMA journal_mode = WAL"); expect(sql).toContain("PRAGMA foreign_keys = OFF"); expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); @@ -57,6 +57,13 @@ describe("mobile app SQLite schema preparation", () => { expect(sql).toContain( "motion_interpolation_enabled INTEGER NOT NULL DEFAULT 1", ); + expect( + sql.indexOf("motion_interpolation_enabled INTEGER NOT NULL"), + ).toBeLessThan( + sql.indexOf( + "distance_green_threshold_steps <= distance_yellow_threshold_steps", + ), + ); expect(sql).toContain("REFERENCES drills(id) ON DELETE CASCADE"); expect(sql).toContain("REFERENCES drill_sets(id) ON DELETE SET NULL"); expect(sql).toContain(`PRAGMA user_version = ${MOBILE_SCHEMA_VERSION}`); diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index 49959e4f..58b8eb76 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -10,7 +10,7 @@ export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; * stable, a version mismatch intentionally rebuilds this disposable database * rather than carrying migration code for development-only layouts. */ -export const MOBILE_SCHEMA_VERSION = 5; +export const MOBILE_SCHEMA_VERSION = 6; export const DRILLS_TABLE = "drills"; export const DRILL_SETS_TABLE = "drill_sets"; @@ -204,9 +204,6 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { distance_yellow_threshold_steps >= 0 AND distance_yellow_threshold_steps = distance_yellow_threshold_steps ), - CHECK ( - distance_green_threshold_steps <= distance_yellow_threshold_steps - ), motion_interpolation_enabled INTEGER NOT NULL DEFAULT 1 CHECK (motion_interpolation_enabled IN (0, 1)), comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 @@ -214,7 +211,10 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { active_drill_id TEXT REFERENCES ${DRILLS_TABLE}(id) ON DELETE SET NULL, selected_drill_page_id TEXT - REFERENCES ${DRILL_SETS_TABLE}(id) ON DELETE SET NULL + REFERENCES ${DRILL_SETS_TABLE}(id) ON DELETE SET NULL, + CHECK ( + distance_green_threshold_steps <= distance_yellow_threshold_steps + ) ); INSERT INTO ${APP_SETTINGS_TABLE} (singleton_id) VALUES (1); From 90e76f6fef5f4b379e956bac26677c25584fbf1f Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 21:25:54 -0500 Subject: [PATCH 081/101] fix(field): Correct yard numbers and dial regressions Measure yard-number glyphs from a stable reference font size before scaling them to six-foot field geometry, center them by visual bounds, and keep both rows upright to the viewer. Remove the brittle dial offset helper dependency and keep the dial knob white independently of theme text color. --- .../field/page-dial/page-dial-canvas.tsx | 6 +- .../field/page-dial/page-dial-layout.ts | 4 +- .../field/page-dial/page-dial-math.ts | 17 ++-- .../features/field/page-dial/page-dial.tsx | 2 +- .../__tests__/yard-number-layout.test.ts | 88 +++++++------------ .../src/field/render/field-static-layer.tsx | 9 +- .../src/field/render/page-dial-canvas.tsx | 6 +- .../src/field/render/yard-number-layout.ts | 12 ++- 8 files changed, 61 insertions(+), 83 deletions(-) diff --git a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx index aba06252..84dd1acf 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx @@ -17,7 +17,7 @@ export function PageDialCanvas({ trackColor, innerColor, backgroundColor, - foregroundColor, + knobColor, dividerColor, }: { readonly diameter: number; @@ -27,7 +27,7 @@ export function PageDialCanvas({ readonly trackColor: string; readonly innerColor?: string; readonly backgroundColor?: string; - readonly foregroundColor?: string; + readonly knobColor?: string; readonly dividerColor?: string; }) { const progress = useDerivedValue(() => { @@ -54,7 +54,7 @@ export function PageDialCanvas({ trackColor={trackColor} innerColor={innerColor} backgroundColor={backgroundColor} - foregroundColor={foregroundColor} + knobColor={knobColor} dividerColor={dividerColor} dividerSegments={dividerSegments} /> diff --git a/apps/mobile/src/features/field/page-dial/page-dial-layout.ts b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts index 0b0b1fec..16f4123f 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-layout.ts +++ b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts @@ -2,11 +2,11 @@ import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill"; import { getPageDialCanvasOverscan, - getPageDialControlCenterOffset, getPageDialControlSize, getPageDialRingHitRegion, getPageDialRingRadius, PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO, + PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO, PAGE_DIAL_INNER_DISK_DIAMETER_RATIO, PAGE_DIAL_KNOB_DIAMETER_RATIO, PAGE_DIAL_RING_THICKNESS_RATIO, @@ -42,7 +42,7 @@ export function getPageDialProportions(diameter: number): PageDialProportions { centerBorderWidth: 0, knobDiameter, knobRadius: knobDiameter / 2, - controlCenterOffset: getPageDialControlCenterOffset(diameter), + controlCenterOffset: diameter * PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO, controlButtonSize: getPageDialControlSize(diameter), ringHitInnerRadius: ringHitRegion.innerRadius, ringHitOuterRadius: ringHitRegion.outerRadius, diff --git a/apps/mobile/src/features/field/page-dial/page-dial-math.ts b/apps/mobile/src/features/field/page-dial/page-dial-math.ts index 2391ee34..3f857ff0 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-math.ts +++ b/apps/mobile/src/features/field/page-dial/page-dial-math.ts @@ -245,22 +245,19 @@ export function pageDialPointIsInRingHitRegion( export const isPageDialRingHit = pageDialPointIsInRingHitRegion; -export function getPageDialControlCenterOffset(diameter: number): number { - "worklet"; - return diameter * PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO; -} - export function getPageDialCardinalPoints( diameter: number, - offset = getPageDialControlCenterOffset(diameter), + offset?: number, ): PageDialCardinalPoints { "worklet"; const center = diameter / 2; + const resolvedOffset = + offset ?? diameter * PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO; return { - top: { x: center, y: center - offset }, - right: { x: center + offset, y: center }, - bottom: { x: center, y: center + offset }, - left: { x: center - offset, y: center }, + top: { x: center, y: center - resolvedOffset }, + right: { x: center + resolvedOffset, y: center }, + bottom: { x: center, y: center + resolvedOffset }, + left: { x: center - resolvedOffset, y: center }, }; } diff --git a/apps/mobile/src/features/field/page-dial/page-dial.tsx b/apps/mobile/src/features/field/page-dial/page-dial.tsx index 82b9c325..0443578f 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial.tsx @@ -103,7 +103,7 @@ export function PageDial({ trackColor={trackColor} innerColor={resolvedInnerColor} backgroundColor={resolvedBackgroundColor} - foregroundColor={resolvedForegroundColor} + knobColor={theme.raw.white} dividerColor={resolvedDividerColor} /> diff --git a/packages/mobile/src/field/__tests__/yard-number-layout.test.ts b/packages/mobile/src/field/__tests__/yard-number-layout.test.ts index 5a84a0c4..5e505217 100644 --- a/packages/mobile/src/field/__tests__/yard-number-layout.test.ts +++ b/packages/mobile/src/field/__tests__/yard-number-layout.test.ts @@ -5,70 +5,48 @@ const bounds = { x: 0.1, y: -0.8, width: 1.4, height: 0.9 }; const targetHeightMeters = feetToMeters(6); describe("yard-number text layout", () => { - test.each(["front", "back"] as const)( - "centers measured %s glyph bounds at an exact six-foot visual height", - (side) => { - const layout = createYardNumberTextLayout( - bounds, - targetHeightMeters, - side, - ); - const transformedCorners = [ - { - x: (layout.x + bounds.x) * layout.scaleX, - y: (layout.y + bounds.y) * layout.scaleY, - }, - { - x: (layout.x + bounds.x + bounds.width) * layout.scaleX, - y: (layout.y + bounds.y + bounds.height) * layout.scaleY, - }, - ]; + test("centers measured glyph bounds at an exact six-foot visual height", () => { + const layout = createYardNumberTextLayout(bounds, targetHeightMeters); + const transformedCorners = [ + { + x: (layout.x + bounds.x) * layout.scaleX, + y: (layout.y + bounds.y) * layout.scaleY, + }, + { + x: (layout.x + bounds.x + bounds.width) * layout.scaleX, + y: (layout.y + bounds.y + bounds.height) * layout.scaleY, + }, + ]; - expect( - (transformedCorners[0].x + transformedCorners[1].x) / 2, - ).toBeCloseTo(0); - expect( - (transformedCorners[0].y + transformedCorners[1].y) / 2, - ).toBeCloseTo(0); - expect(layout.visualHeightMeters).toBeCloseTo(targetHeightMeters); - expect( - Math.abs(transformedCorners[1].y - transformedCorners[0].y), - ).toBeCloseTo(targetHeightMeters); - }, - ); - - test("faces the front and back rows toward opposite sidelines", () => { - const front = createYardNumberTextLayout( - bounds, - targetHeightMeters, - "front", + expect((transformedCorners[0].x + transformedCorners[1].x) / 2).toBeCloseTo( + 0, + ); + expect((transformedCorners[0].y + transformedCorners[1].y) / 2).toBeCloseTo( + 0, ); - const back = createYardNumberTextLayout(bounds, targetHeightMeters, "back"); + expect(layout.visualHeightMeters).toBeCloseTo(targetHeightMeters); + expect( + Math.abs(transformedCorners[1].y - transformedCorners[0].y), + ).toBeCloseTo(targetHeightMeters); + }); - expect(front.scaleX).toBeGreaterThan(0); - expect(front.scaleY).toBeLessThan(0); - expect(back.scaleX).toBeLessThan(0); - expect(back.scaleY).toBeGreaterThan(0); + test("counteracts the field Y reflection so every row is upright to the viewer", () => { + const layout = createYardNumberTextLayout(bounds, targetHeightMeters); - // FieldScene reflects world Y into screen Y. The front row is upright on - // screen; the back row is rotated 180 degrees to face the back sideline. - expect({ x: front.scaleX, y: -front.scaleY }).toEqual({ - x: Math.abs(front.scaleX), - y: Math.abs(front.scaleY), - }); - expect({ x: back.scaleX, y: -back.scaleY }).toEqual({ - x: -Math.abs(back.scaleX), - y: -Math.abs(back.scaleY), + expect(layout.scaleX).toBeGreaterThan(0); + expect(layout.scaleY).toBeLessThan(0); + + // FieldScene applies scaleY(-1). Combining that with this local Y + // reflection produces an ordinary positive-X/positive-Y screen transform. + expect({ x: layout.scaleX, y: -layout.scaleY }).toEqual({ + x: Math.abs(layout.scaleX), + y: Math.abs(layout.scaleY), }); }); test("rejects unusable visual bounds", () => { expect(() => - createYardNumberTextLayout( - { ...bounds, height: 0 }, - targetHeightMeters, - "front", - ), + createYardNumberTextLayout({ ...bounds, height: 0 }, targetHeightMeters), ).toThrow(RangeError); }); }); diff --git a/packages/mobile/src/field/render/field-static-layer.tsx b/packages/mobile/src/field/render/field-static-layer.tsx index fd436d03..d5858629 100644 --- a/packages/mobile/src/field/render/field-static-layer.tsx +++ b/packages/mobile/src/field/render/field-static-layer.tsx @@ -8,6 +8,8 @@ import type { FieldPaths } from "./create-field-paths"; import type { FieldRenderPalette } from "./field-render-tokens"; import { createYardNumberTextLayout } from "./yard-number-layout"; +const YARD_NUMBER_MEASUREMENT_FONT_SIZE = 100; + interface FieldStaticLayerProps { readonly template: StandardFootballFieldTemplate; readonly paths: FieldPaths; @@ -29,9 +31,13 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ const fourStepStroke = useDerivedValue(() => metersPerPixel.value * 1.1); const fieldLineStroke = useDerivedValue(() => metersPerPixel.value * 1.4); const boundaryStroke = useDerivedValue(() => metersPerPixel.value * 2); + // Measure/draw from a large, fixed reference size and scale into world + // meters afterward. Measuring Montserrat at ~1.83 "font units" (six feet) + // is small enough for hinting/rounding to distort the glyph bounds on some + // platforms, which made the painted numbers undersized and slightly offset. const numberFont = useFont( Montserrat_600SemiBold, - template.dimensions.yardNumberHeightMeters, + YARD_NUMBER_MEASUREMENT_FONT_SIZE, ); const fieldClip = { x: template.bounds.minXMeters, @@ -117,7 +123,6 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ const layout = createYardNumberTextLayout( numberFont.measureText(number.label), number.heightMeters, - number.side, ); return ( {/* A larger, overscanned knob keeps its soft offset shadow inside the canvas. */} - + Date: Wed, 5 Aug 2026 22:11:51 -0500 Subject: [PATCH 082/101] fix(drill): Polish upload controls Give closed drill dialogs distinct React keys to avoid duplicate-key warnings. Present the empty-state upload action as blue icon and text without a filled button background. --- .../features/drill/components/drill-empty-state.tsx | 11 ++++++++--- apps/mobile/src/features/drill/drill-list-screen.tsx | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/drill/components/drill-empty-state.tsx b/apps/mobile/src/features/drill/components/drill-empty-state.tsx index fcbd7a78..246baddc 100644 --- a/apps/mobile/src/features/drill/components/drill-empty-state.tsx +++ b/apps/mobile/src/features/drill/components/drill-empty-state.tsx @@ -39,9 +39,14 @@ export function DrillEmptyState({ Upload an Eight2Five drill file to start working with its{" "} {terms.lowercasePlural}. - diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx index 126d9304..3ea86253 100644 --- a/apps/mobile/src/features/drill/drill-list-screen.tsx +++ b/apps/mobile/src/features/drill/drill-list-screen.tsx @@ -105,7 +105,7 @@ export function DrillListScreen() { ? `import:${controller.pendingImport.fileName}` : controller.performerDialog ? `performer:${controller.performerDialog.drill?.id}:${controller.performerDialog.drill?.selectedPerformerEntityId ?? "none"}` - : "closed" + : "performer:closed" } document={ controller.pendingImport?.document ?? @@ -146,7 +146,7 @@ export function DrillListScreen() { key={ controller.propertiesDialog ? `${controller.propertiesDialog.drill.id}:${controller.propertiesDialog.drill.updatedAt}` - : "closed" + : "properties:closed" } drill={controller.propertiesDialog?.drill} document={controller.propertiesDialog?.document} From fab7c084342c0fa0976f3855f6794995a411d268 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 22:11:59 -0500 Subject: [PATCH 083/101] fix(field): Restore drill metric switching Switch counts, measures, step size, and xCounts with a clipped vertical slide and paired mode indicators. Keep expanded rows on the same text palette as the header and round crossing counts to real counts within the move. --- .../__tests__/transition-presentation.test.ts | 18 +++++++ .../features/drill/transition-presentation.ts | 8 ++- .../__tests__/drill-pill-presentation.test.ts | 4 +- .../drill-pill/animated-value-switch.tsx | 28 +++++++--- .../drill-pill/drill-pill-presentation.ts | 2 +- .../field/drill-pill/drill-set-list.tsx | 5 +- .../drill-pill/drill-set-metric-grid.tsx | 52 ++++++++++++++++--- 7 files changed, 96 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts diff --git a/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts b/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts new file mode 100644 index 00000000..8247b49c --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts @@ -0,0 +1,18 @@ +import { formatTransitionAnalysis } from "../transition-presentation"; + +describe("transition presentation", () => { + test("rounds yard-line crossing xCounts to the nearest whole count", () => { + expect( + formatTransitionAnalysis( + { + distanceSteps: 24, + stepSizeToFive: 5.33, + isHalt: false, + yardLineCrossingCounts: [0.2, 5.333333, 10.666667], + }, + true, + 16, + ).crossingCounts, + ).toBe("1, 5, 11"); + }); +}); diff --git a/apps/mobile/src/features/drill/transition-presentation.ts b/apps/mobile/src/features/drill/transition-presentation.ts index 1084f09b..33358b58 100644 --- a/apps/mobile/src/features/drill/transition-presentation.ts +++ b/apps/mobile/src/features/drill/transition-presentation.ts @@ -25,7 +25,9 @@ export function formatTransitionAnalysis( : `${formatMetricNumber(analysis.stepSizeToFive)} to 5`, crossingCounts: analysis.yardLineCrossingCounts.length > 0 - ? analysis.yardLineCrossingCounts.map(formatMetricNumber).join(", ") + ? analysis.yardLineCrossingCounts + .map((count) => formatCrossingCount(count, countsFromPrevious)) + .join(", ") : "–", }; } @@ -44,3 +46,7 @@ export function getTransitionPresentation( function formatMetricNumber(value: number): string { return Number(value.toFixed(6)).toString(); } + +function formatCrossingCount(value: number, moveCounts: number): string { + return String(Math.min(moveCounts, Math.max(1, Math.round(value)))); +} diff --git a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts index b09716bf..a9905bd9 100644 --- a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts +++ b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts @@ -40,8 +40,8 @@ describe("drill pill metric modes", () => { getTransitionMetricPresentation(row, "crossing-counts"), ), ).toMatchObject([ - { key: "crossing-counts", label: "xCounts", direction: -1 }, - { key: "crossing-counts", label: "xCounts", direction: -1 }, + { key: "crossing-counts", label: "xCounts", direction: 1 }, + { key: "crossing-counts", label: "xCounts", direction: 1 }, ]); }); }); diff --git a/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx index b2ea7763..7464a3da 100644 --- a/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx +++ b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx @@ -1,10 +1,10 @@ import React from "react"; import { View, type StyleProp, type ViewStyle } from "react-native"; import Animated, { - FadeInLeft, - FadeInRight, - FadeOutLeft, - FadeOutRight, + FadeInDown, + FadeInUp, + FadeOutDown, + FadeOutUp, ReduceMotion, } from "react-native-reanimated"; @@ -21,16 +21,28 @@ export function AnimatedValueSwitch({ readonly style?: StyleProp; readonly testID?: string; }) { - const entering = (direction > 0 ? FadeInRight : FadeInLeft) + const entering = (direction > 0 ? FadeInUp : FadeInDown) .duration(180) .reduceMotion(ReduceMotion.System); - const exiting = (direction > 0 ? FadeOutLeft : FadeOutRight) + const exiting = (direction > 0 ? FadeOutUp : FadeOutDown) .duration(180) .reduceMotion(ReduceMotion.System); return ( - - + + {children} diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts index ad6c0ded..a1cc2bad 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts +++ b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts @@ -37,7 +37,7 @@ export function getTransitionMetricPresentation( ): AnimatedMetricPresentation { return { key: mode, - direction: mode === "step-size" ? 1 : -1, + direction: mode === "step-size" ? -1 : 1, label: presentation.metricLabel, value: presentation.metric, }; diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx index 37df3bea..07a5c843 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx @@ -70,7 +70,7 @@ export function DrillSetList({ style={{ height: DRILL_SET_ROW_HEIGHT, justifyContent: "center", - backgroundColor: selected ? theme.accent : "transparent", + backgroundColor: selected ? theme.accentSoft : "transparent", }} testID={`drill-set-row-${index}`} > @@ -79,7 +79,6 @@ export function DrillSetList({ columns={columns} countDisplayMode={countDisplayMode} metricMode={metricMode} - selected={selected} /> ); @@ -93,7 +92,7 @@ export function DrillSetList({ pages, selectedIndex, terminology, - theme.accent, + theme.accentSoft, ], ); diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx index a04dcfe2..26717360 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx @@ -1,5 +1,6 @@ import React from "react"; import { ChevronDown, ChevronUp } from "lucide-react-native"; +import { Box } from "@eight2five/ui/components/box"; import { HStack } from "@eight2five/ui/components/hstack"; import { Icon } from "@eight2five/ui/components/icon"; import { Pressable } from "@eight2five/ui/components/pressable"; @@ -28,7 +29,6 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ columns, countDisplayMode, metricMode, - selected = false, header = false, expanded = false, onToggleCounts, @@ -39,7 +39,6 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ readonly columns: DrillPillColumnMetrics; readonly countDisplayMode: CountDisplayMode; readonly metricMode: TransitionMetricMode; - readonly selected?: boolean; readonly header?: boolean; readonly expanded?: boolean; readonly onToggleCounts?: () => void; @@ -47,8 +46,8 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ readonly onToggleExpanded?: () => void; }) { const theme = useEight2FiveTheme(); - const labelColor = selected ? theme.raw.white : theme.textMuted; - const valueColor = selected ? theme.raw.white : theme.text; + const labelColor = theme.textMuted; + const valueColor = theme.text; const count = getCountMetricPresentation(presentation, countDisplayMode); const metric = getTransitionMetricPresentation(presentation, metricMode); @@ -90,6 +89,7 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ value={count.value} labelColor={labelColor} valueColor={valueColor} + modeIndex={countDisplayMode === "counts" ? 0 : 1} /> @@ -115,6 +115,7 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ value={metric.value} labelColor={labelColor} valueColor={valueColor} + modeIndex={metricMode === "step-size" ? 0 : 1} /> @@ -174,15 +175,17 @@ function MetricCell({ value, labelColor, valueColor, + modeIndex, }: { readonly width?: number; readonly label: string; readonly value: string; readonly labelColor: string; readonly valueColor: string; + readonly modeIndex?: 0 | 1; }) { - return ( - + const content = ( + ); + + if (modeIndex === undefined) { + return ( + + {content} + + ); + } + + return ( + + + + + + {content} + + ); } From 9bd4db01342810e6cb60632e5e1a6331f629a528 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 22:12:06 -0500 Subject: [PATCH 084/101] fix(field): Repair tag connection modal lifecycle Let the field HUD modal own tag discovery for its mounted lifetime instead of relying on route focus hooks. Keep navigation-only controls in the dedicated settings route while sharing the same connection body. --- .../settings/tag-connection-screen.tsx | 85 +++++++++++++++---- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/settings/tag-connection-screen.tsx b/apps/mobile/src/features/settings/tag-connection-screen.tsx index f4a3ed29..9cc46c99 100644 --- a/apps/mobile/src/features/settings/tag-connection-screen.tsx +++ b/apps/mobile/src/features/settings/tag-connection-screen.tsx @@ -54,16 +54,78 @@ const SIGNAL_ICONS: Record = { }; export function TagConnectionScreen() { - return ; + return ; } -/** Shared route/modal body. Discovery ownership follows focus lifecycle. */ +/** + * Shared route/modal entry point. Modal content owns discovery for its mounted + * lifetime instead of depending on navigation focus, which keeps it usable + * when rendered through the field HUD modal portal. + */ export function TagConnectionContent({ modal = false, }: { readonly modal?: boolean; }) { + return modal ? ( + + ) : ( + + ); +} + +function FocusedTagConnectionContent() { const router = useRouter(); + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const [lifecycleError, setLifecycleError] = React.useState(); + + useFocusEffect( + React.useCallback(() => { + setLifecycleError(undefined); + return ownTagDiscoveryWhileFocused( + store, + snapshot.initialization === "ready", + store.getSnapshot().connectionState === "connected", + setLifecycleError, + ); + }, [snapshot.initialization, store]), + ); + + return ( + router.push("/(tabs)/settings/networks" as never)} + /> + ); +} + +function MountedTagConnectionContent() { + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const [lifecycleError, setLifecycleError] = React.useState(); + + React.useEffect(() => { + return ownTagDiscoveryWhileFocused( + store, + snapshot.initialization === "ready", + store.getSnapshot().connectionState === "connected", + setLifecycleError, + ); + }, [snapshot.initialization, store]); + + return ; +} + +function TagConnectionBody({ + modal = false, + lifecycleError, + onOpenNetworks, +}: { + readonly modal?: boolean; + readonly lifecycleError?: Error; + readonly onOpenNetworks?: () => void; +}) { const theme = useEight2FiveTheme(); const store = useMobilePansStore(); const snapshot = useMobilePansSnapshot(); @@ -93,17 +155,6 @@ export function TagConnectionContent({ ? labelEdit.value : selectedLabel; - useFocusEffect( - React.useCallback(() => { - return ownTagDiscoveryWhileFocused( - store, - snapshot.initialization === "ready", - store.getSnapshot().connectionState === "connected", - setError, - ); - }, [snapshot.initialization, store]), - ); - const run = async (action: () => Promise) => { if (operation) return; setOperation(true); @@ -119,9 +170,9 @@ export function TagConnectionContent({ const content = ( <> - {snapshot.error || error ? ( + {snapshot.error || lifecycleError || error ? ( - {(error ?? snapshot.error)?.message} + {(error ?? lifecycleError ?? snapshot.error)?.message} ) : null} @@ -251,11 +302,11 @@ export function TagConnectionContent({ disabled={operation} testID="active-network-setting" /> - {!modal ? ( + {!modal && onOpenNetworks ? ( router.push("/(tabs)/settings/networks" as never)} + onPress={onOpenNetworks} testID="network-management-link" /> ) : null} From 42eb5a5ac8e779a0e16f73762053819dcda24381 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Wed, 5 Aug 2026 23:47:40 -0500 Subject: [PATCH 085/101] fix(mobile): Refine field and developer controls --- apps/mobile/app/(tabs)/drill/_layout.tsx | 1 + apps/mobile/app/(tabs)/settings/_layout.tsx | 5 +- .../settings/developer-confirmation.tsx | 5 +- apps/mobile/package.json | 2 +- .../drill/components/drill-empty-state.tsx | 23 ++- .../src/features/drill/drill-list-screen.tsx | 5 +- .../features/field/drill-pill/drill-pill.tsx | 40 +++--- .../drill-pill/drill-set-metric-grid.tsx | 32 +++-- .../features/field/field-frosted-surface.tsx | 84 +++++++++++ .../features/field/field-overlay-layout.tsx | 74 +++++----- .../src/features/field/field-screen.tsx | 63 +++++++-- .../src/features/field/live-position-hud.tsx | 94 ++++++------- .../field/page-dial/page-dial-canvas.tsx | 18 +-- .../field/page-dial/page-dial-controls.tsx | 41 +++++- .../features/field/page-dial/page-dial.tsx | 41 +++++- .../settings/__tests__/developer-mode.test.ts | 14 +- .../developer-confirmation-screen.tsx | 87 +----------- .../settings/developer-mode-actions.ts | 18 ++- .../settings/developer-settings-screen.tsx | 132 +++++++++++++++--- .../features/settings/settings-components.tsx | 100 ++++++++----- .../src/pans/__tests__/mobile-pans-ui.test.ts | 4 +- apps/mobile/src/pans/mobile-pans-ui.ts | 4 +- package-lock.json | 2 +- .../src/settings/SqliteSettingsRepository.ts | 27 +++- .../src/settings/__tests__/repository.test.ts | 32 ++++- packages/mobile/src/settings/types.ts | 28 ++++ .../storage/__tests__/mobileDatabase.test.ts | 7 +- packages/mobile/src/storage/mobileDatabase.ts | 8 +- packages/ui/components/modal/index.tsx | 5 +- 29 files changed, 657 insertions(+), 339 deletions(-) create mode 100644 apps/mobile/src/features/field/field-frosted-surface.tsx diff --git a/apps/mobile/app/(tabs)/drill/_layout.tsx b/apps/mobile/app/(tabs)/drill/_layout.tsx index c1afa52e..47e9fd5f 100644 --- a/apps/mobile/app/(tabs)/drill/_layout.tsx +++ b/apps/mobile/app/(tabs)/drill/_layout.tsx @@ -19,6 +19,7 @@ export default function DrillLayout() { - ; + return ; } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 46438a03..a9a83be7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,8 +20,8 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "@expo/ui": "~57.0.9", "expo": "~57.0.10", + "expo-blur": "~57.0.2", "expo-dev-client": "~57.0.10", "expo-router": "~57.0.10", "react": "19.2.3", diff --git a/apps/mobile/src/features/drill/components/drill-empty-state.tsx b/apps/mobile/src/features/drill/components/drill-empty-state.tsx index 246baddc..7b9c1353 100644 --- a/apps/mobile/src/features/drill/components/drill-empty-state.tsx +++ b/apps/mobile/src/features/drill/components/drill-empty-state.tsx @@ -1,5 +1,4 @@ import { FileUp } from "lucide-react-native"; -import type { DrillTerms } from "@eight2five/mobile/drill"; import { Button, ButtonIcon, @@ -15,13 +14,7 @@ import { useEight2FiveTheme, } from "@eight2five/ui/theme"; -export function DrillEmptyState({ - terms, - onUpload, -}: { - terms: DrillTerms; - onUpload(): void; -}) { +export function DrillEmptyState({ onUpload }: { onUpload(): void }) { const theme = useEight2FiveTheme(); return (
@@ -36,17 +29,21 @@ export function DrillEmptyState({ No drills yet - Upload an Eight2Five drill file to start working with its{" "} - {terms.lowercasePlural}. + Upload an Eight2Five drill file to start working with it.
diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx index 3ea86253..d6c5d857 100644 --- a/apps/mobile/src/features/drill/drill-list-screen.tsx +++ b/apps/mobile/src/features/drill/drill-list-screen.tsx @@ -91,10 +91,7 @@ export function DrillListScreen() { } ListEmptyComponent={ controller.loading ? null : ( - void controller.pickFile()} - /> + void controller.pickFile()} /> ) } /> diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill.tsx b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx index dc4038e2..489b3f22 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-pill.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx @@ -10,7 +10,6 @@ import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; import type { TransitionMetricMode } from "@eight2five/mobile/settings"; import { Divider } from "@eight2five/ui/components/divider"; import { Text } from "@eight2five/ui/components/text"; -import { VStack } from "@eight2five/ui/components/vstack"; import { eight2FiveRadii, eight2FiveSpacing, @@ -24,6 +23,7 @@ import { import { getDrillPillColumnMetrics } from "./drill-pill-layout"; import { DRILL_SET_ROW_HEIGHT, DrillSetList } from "./drill-set-list"; import { DrillSetMetricGrid } from "./drill-set-metric-grid"; +import { FrostedFieldSurface } from "../field-frosted-surface"; export function DrillPill({ width, @@ -57,7 +57,7 @@ export function DrillPill({ readonly error?: Error; readonly onToggleCounts: () => void; readonly onToggleMetric: () => void; - readonly onToggleExpanded: () => void; + readonly onToggleExpanded?: () => void; readonly onSelectIndex: (index: number) => void; }) { const theme = useEight2FiveTheme(); @@ -77,26 +77,26 @@ export function DrillPill({ listMaxHeight, Math.max(0, pages.length * DRILL_SET_ROW_HEIGHT + 1), ); - const animatedHeight = useSharedValue(expanded ? availableListHeight : 0); + const effectiveExpanded = Boolean(onToggleExpanded && expanded); + const animatedHeight = useSharedValue( + effectiveExpanded ? availableListHeight : 0, + ); React.useEffect(() => { - animatedHeight.value = withTiming(expanded ? availableListHeight : 0, { - duration: 220, - reduceMotion: ReduceMotion.System, - }); - }, [animatedHeight, availableListHeight, expanded]); + animatedHeight.value = withTiming( + effectiveExpanded ? availableListHeight : 0, + { + duration: 220, + reduceMotion: ReduceMotion.System, + }, + ); + }, [animatedHeight, availableListHeight, effectiveExpanded]); const listStyle = useAnimatedStyle(() => ({ height: animatedHeight.value })); return ( -
- + ); } diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx index 26717360..e3e46b3e 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx @@ -131,15 +131,15 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ onPress={onToggleExpanded} style={{ width: columns.coordinateWidth }} > - - + + - Marching Coordinate + Coordinate - {onToggleExpanded ? ( + {header && onToggleExpanded ? ( ) : null} @@ -185,7 +188,10 @@ function MetricCell({ readonly modeIndex?: 0 | 1; }) { const content = ( - + + {content} ); @@ -221,7 +233,11 @@ function MetricCell({ return ( | null>(null); + +export function FrostedFieldSurface({ + children, + borderRadius, + style, + intensity = 82, + overlayOpacity = 0.72, + shadow = true, + testID, + accessibilityLabel, +}: { + readonly children: React.ReactNode; + readonly borderRadius: number; + readonly style?: StyleProp; + readonly intensity?: number; + readonly overlayOpacity?: number; + readonly shadow?: boolean; + readonly testID?: string; + readonly accessibilityLabel?: string; +}) { + const theme = useEight2FiveTheme(); + const themeName = useEight2FiveThemeName(); + const blurTarget = React.useContext(FieldBlurTargetContext); + + return ( + + + + {children} + + ); +} + +function colorWithOpacity(color: string, opacity: number): string { + const hex = /^#([0-9a-f]{6})$/i.exec(color); + if (!hex) return color; + const value = hex[1]; + const red = Number.parseInt(value.slice(0, 2), 16); + const green = Number.parseInt(value.slice(2, 4), 16); + const blue = Number.parseInt(value.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${opacity})`; +} diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx index 198d8dc3..7a8a9916 100644 --- a/apps/mobile/src/features/field/field-overlay-layout.tsx +++ b/apps/mobile/src/features/field/field-overlay-layout.tsx @@ -1,5 +1,8 @@ import React from "react"; +import { BlurTargetView } from "expo-blur"; import { View, type ViewStyle } from "react-native"; + +import { FieldBlurTargetContext } from "./field-frosted-surface"; import { useSafeAreaInsets, type EdgeInsets, @@ -154,6 +157,7 @@ export function FieldOverlayLayout({ dial, }: FieldOverlayLayoutProps) { const insets = useSafeAreaInsets(); + const blurTargetRef = React.useRef(null); const metrics = getFieldOverlayMetrics({ width, height, @@ -163,38 +167,42 @@ export function FieldOverlayLayout({ }); return ( - - {field} - {hud ? ( - - {hud(metrics)} - - ) : null} - {live ? ( - - {live(metrics.controlDiameter)} - - ) : null} - {dial ? ( - - {dial(metrics.controlDiameter)} - - ) : null} - + + + + {field} + + {hud ? ( + + {hud(metrics)} + + ) : null} + {live ? ( + + {live(metrics.controlDiameter)} + + ) : null} + {dial ? ( + + {dial(metrics.controlDiameter)} + + ) : null} + + ); } diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index f5815507..14a90529 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -58,13 +58,45 @@ export function FieldScreen({ const [drillDialogOpen, setDrillDialogOpen] = React.useState(false); const [performerDialogOpen, setPerformerDialogOpen] = React.useState(false); const [tagDialogOpen, setTagDialogOpen] = React.useState(false); - const liveState = livePosition?.state ?? EMPTY_FIELD_LIVE_POSITION_STATE; + const pansLiveState = livePosition?.state ?? EMPTY_FIELD_LIVE_POSITION_STATE; + const mockLivePositionEnabled = + controller.settings.developerModeEnabled && + controller.settings.mockLivePositionEnabled; + const mockPosition = React.useMemo( + () => + mockLivePositionEnabled + ? drillGridPointToFieldPoint( + { + xSteps: controller.settings.mockLivePositionXSteps, + ySteps: controller.settings.mockLivePositionYSteps, + }, + controller.fieldPreset, + ) + : null, + [ + controller.fieldPreset, + controller.settings.mockLivePositionXSteps, + controller.settings.mockLivePositionYSteps, + mockLivePositionEnabled, + ], + ); + const liveState = mockPosition + ? { + ...pansLiveState, + position: mockPosition, + isStale: false, + interpolationActive: false, + } + : pansLiveState; const fallbackLivePosition = useSharedValue( - liveState.position ?? null, + pansLiveState.position ?? null, ); - const livePositionValue = livePosition?.positionValue ?? fallbackLivePosition; - const liveXMeters = liveState.position?.xMeters; - const liveYMeters = liveState.position?.yMeters; + const mockLivePosition = useSharedValue(mockPosition); + const livePositionValue = mockLivePositionEnabled + ? mockLivePosition + : (livePosition?.positionValue ?? fallbackLivePosition); + const liveXMeters = pansLiveState.position?.xMeters; + const liveYMeters = pansLiveState.position?.yMeters; React.useEffect(() => { if (livePosition?.positionValue) return; setLivePositionValue( @@ -79,6 +111,13 @@ export function FieldScreen({ liveXMeters, liveYMeters, ]); + React.useEffect(() => { + setLivePositionValue(mockLivePosition, mockPosition); + }, [mockLivePosition, mockPosition]); + const activeDrillId = controller.settings.activeDrillId; + React.useEffect(() => { + dispatchHud({ type: "collapse-drill-pill" }); + }, [activeDrillId]); const drillOverlayState = { drillFeaturesEnabled: controller.settings.drillFeaturesEnabled, hasActiveDrill: Boolean(controller.activeDrill), @@ -115,6 +154,9 @@ export function FieldScreen({ controller.settingsStatus !== "ready" || controller.loadingDrills || controller.selectionBusy; + const canExpandDrillPill = Boolean( + controller.activeDrill && controller.pages.length > 0, + ); const terms = getDrillTerms(controller.settings.drillTerminology); const palette = React.useMemo( () => ({ @@ -172,15 +214,17 @@ export function FieldScreen({ countDisplayMode={hudState.countDisplayMode} metricMode={controller.settings.transitionMetricMode} fieldPreset={controller.fieldPreset} - expanded={hudState.drillPillExpanded} + expanded={canExpandDrillPill && hudState.drillPillExpanded} controlsDisabled={controlsDisabled} error={controller.error} onToggleCounts={() => dispatchHud({ type: "toggle-count-display" }) } onToggleMetric={() => void controller.toggleMetricMode()} - onToggleExpanded={() => - dispatchHud({ type: "toggle-drill-pill" }) + onToggleExpanded={ + canExpandDrillPill + ? () => dispatchHud({ type: "toggle-drill-pill" }) + : undefined } onSelectIndex={(index) => void controller.selectPageAtIndex(index) @@ -229,10 +273,7 @@ export function FieldScreen({ terminology={controller.settings.drillTerminology} activeColor={theme.accent} trackColor={theme.border} - innerColor={theme.surfaceRaised} - backgroundColor={theme.surface} foregroundColor={theme.text} - dividerColor={theme.textSubtle} onSelectIndex={(index) => void controller.selectPageAtIndex(index) } diff --git a/apps/mobile/src/features/field/live-position-hud.tsx b/apps/mobile/src/features/field/live-position-hud.tsx index e9bc5f12..0c08cfe7 100644 --- a/apps/mobile/src/features/field/live-position-hud.tsx +++ b/apps/mobile/src/features/field/live-position-hud.tsx @@ -31,6 +31,7 @@ import { getTargetDistancePresentation, type DistanceTone, } from "./live-position-hud-state"; +import { FrostedFieldSurface } from "./field-frosted-surface"; export function LivePositionSquare({ diameter, @@ -58,54 +59,50 @@ export function LivePositionSquare({ }); const distanceColor = colorForDistanceTone(distance.tone, theme); + const radius = Math.min(eight2FiveRadii.lg, diameter * 0.16); return ( - - - - - + - + - {distance.value} - - - + + + {distance.value} + + + + ); } @@ -120,17 +117,10 @@ export function LiveOnlyPill({ readonly fieldPreset: FieldPresetId; readonly onOpenTagConnection: () => void; }) { - const theme = useEight2FiveTheme(); return ( - - + ); } diff --git a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx index 84dd1acf..52d53c58 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx @@ -1,13 +1,10 @@ import { FieldPageDialCanvas } from "@eight2five/mobile/field/render"; -import { useMemo } from "react"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import { - getPageDialDividerSegments, PAGE_DIAL_START_ANGLE_DEGREES, PAGE_DIAL_USABLE_ARC_DEGREES, } from "./page-dial-math"; -import { getPageDialProportions } from "./page-dial-layout"; export function PageDialCanvas({ diameter, @@ -18,7 +15,6 @@ export function PageDialCanvas({ innerColor, backgroundColor, knobColor, - dividerColor, }: { readonly diameter: number; readonly pageCount: number; @@ -28,22 +24,11 @@ export function PageDialCanvas({ readonly innerColor?: string; readonly backgroundColor?: string; readonly knobColor?: string; - readonly dividerColor?: string; }) { const progress = useDerivedValue(() => { if (pageCount <= 0 || !Number.isFinite(provisionalProgress.value)) return 0; return Math.min(1, Math.max(0, provisionalProgress.value)); }); - const proportions = getPageDialProportions(diameter); - const dividerSegments = useMemo( - () => - getPageDialDividerSegments( - diameter, - proportions.innerDiskDiameter, - proportions.centerDiskDiameter, - ), - [diameter, proportions.centerDiskDiameter, proportions.innerDiskDiameter], - ); return ( ); } diff --git a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx index 1572345e..98ebf296 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx @@ -1,4 +1,6 @@ +import type { ViewStyle } from "react-native"; import { Center } from "@eight2five/ui/components/center"; +import { Divider } from "@eight2five/ui/components/divider"; import { Icon } from "@eight2five/ui/components/icon"; import { Pressable } from "@eight2five/ui/components/pressable"; import { Text } from "@eight2five/ui/components/text"; @@ -13,6 +15,8 @@ import { import { getPageDialCardinalPoints, getPageDialControlSize, + getPageDialDividerSegments, + type PageDialLineSegment, } from "./page-dial-math"; export function PageDialControls({ @@ -48,6 +52,11 @@ export function PageDialControls({ proportions.controlCenterOffset, ); const centerDiameter = proportions.centerDiskDiameter; + const dividerSegments = getPageDialDividerSegments( + diameter, + proportions.innerDiskDiameter, + centerDiameter, + ); const buttonStyle = (x: number, y: number, disabled = false) => ({ position: "absolute" as const, left: x - buttonSize / 2, @@ -61,6 +70,14 @@ export function PageDialControls({ return ( <> + {dividerSegments.map((segment, index) => ( + + ))} {terms.plural} ); } + +function getDividerStyle(segment: PageDialLineSegment): ViewStyle { + const dx = segment.end.x - segment.start.x; + const dy = segment.end.y - segment.start.y; + const length = Math.hypot(dx, dy); + const midpointX = (segment.start.x + segment.end.x) / 2; + const midpointY = (segment.start.y + segment.end.y) / 2; + const rotationDegrees = (Math.atan2(dy, dx) * 180) / Math.PI - 90; + return { + position: "absolute", + left: midpointX - 0.5, + top: midpointY - length / 2, + width: 1, + height: length, + transform: [{ rotate: `${rotationDegrees}deg` }], + }; +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial.tsx b/apps/mobile/src/features/field/page-dial/page-dial.tsx index 0443578f..429190ac 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial.tsx @@ -7,7 +7,12 @@ import { type SharedValue, } from "react-native-reanimated"; import type { DrillTerminology } from "@eight2five/mobile/drill"; -import { useEight2FiveTheme } from "@eight2five/ui/theme"; +import { + useEight2FiveTheme, + useEight2FiveThemeName, +} from "@eight2five/ui/theme"; + +import { FrostedFieldSurface } from "../field-frosted-surface"; import { PageDialCanvas } from "./page-dial-canvas"; import { PageDialControls } from "./page-dial-controls"; @@ -32,7 +37,6 @@ export function PageDial({ innerColor, backgroundColor, foregroundColor, - dividerColor, onSelectIndex, onSelectDrill, onSelectPerformer, @@ -47,12 +51,12 @@ export function PageDial({ readonly innerColor?: string; readonly backgroundColor?: string; readonly foregroundColor?: string; - readonly dividerColor?: string; readonly onSelectIndex: (index: number) => void; readonly onSelectDrill?: () => void; readonly onSelectPerformer?: () => void; }) { const theme = useEight2FiveTheme(); + const themeName = useEight2FiveThemeName(); const provisionalProgress = useSharedValue( normalizePageIndex(Math.max(0, selectedIndex), pageCount), ); @@ -85,17 +89,31 @@ export function PageDial({ [onSelectIndex, pageCount, provisionalProgress, selectedIndex], ); - const resolvedInnerColor = innerColor ?? theme.surfaceRaised; - const resolvedBackgroundColor = backgroundColor ?? theme.background; + const resolvedInnerColor = + innerColor ?? colorWithOpacity(theme.surfaceRaised, 0.38); + const resolvedBackgroundColor = backgroundColor ?? "transparent"; const resolvedForegroundColor = foregroundColor ?? theme.text; - const resolvedDividerColor = dividerColor ?? theme.border; return ( + + + ); } + +function colorWithOpacity(color: string, opacity: number): string { + const match = /^#([0-9a-f]{6})$/i.exec(color); + if (!match) return color; + const hex = match[1]; + const red = Number.parseInt(hex.slice(0, 2), 16); + const green = Number.parseInt(hex.slice(2, 4), 16); + const blue = Number.parseInt(hex.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${opacity})`; +} diff --git a/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts index c1d7b0ba..0eb18bec 100644 --- a/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts +++ b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts @@ -2,31 +2,33 @@ import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; import { buildDeveloperDiagnosticRows } from "../developer-diagnostics"; import { - DEVELOPER_MODE_WARNING, canUseDeveloperControls, disableDeveloperMode, enableDeveloperMode, } from "../developer-mode-actions"; describe("Developer Mode", () => { - test("enables only through the explicit confirmation action", async () => { + test("enables directly through the developer settings toggle", async () => { const enabled = { ...DEFAULT_APP_SETTINGS, developerModeEnabled: true }; const writer = { update: jest.fn(async () => enabled) }; await expect(enableDeveloperMode(writer)).resolves.toEqual(enabled); expect(writer.update).toHaveBeenCalledWith({ developerModeEnabled: true }); - expect(DEVELOPER_MODE_WARNING).toContain("modify PANS anchor positions"); - expect(DEVELOPER_MODE_WARNING).toContain("reported locations inaccurate"); }); - test("disabling hides controls without changing another preference", async () => { + test("disabling hides controls and clears developer-only live mocking", async () => { const disabled = { ...DEFAULT_APP_SETTINGS, developerModeEnabled: false }; const writer = { update: jest.fn(async () => disabled) }; await disableDeveloperMode(writer); - expect(writer.update).toHaveBeenCalledWith({ developerModeEnabled: false }); + expect(writer.update).toHaveBeenCalledWith({ + developerModeEnabled: false, + mockLivePositionEnabled: false, + mockLivePositionXSteps: 0, + mockLivePositionYSteps: 0, + }); expect(canUseDeveloperControls(disabled)).toBe(false); }); diff --git a/apps/mobile/src/features/settings/developer-confirmation-screen.tsx b/apps/mobile/src/features/settings/developer-confirmation-screen.tsx index 5296943a..d60b3f11 100644 --- a/apps/mobile/src/features/settings/developer-confirmation-screen.tsx +++ b/apps/mobile/src/features/settings/developer-confirmation-screen.tsx @@ -1,85 +1,2 @@ -import React from "react"; -import { useRouter } from "expo-router"; -import { Code2, TriangleAlert, X } from "lucide-react-native"; -import { - Button, - ButtonIcon, - ButtonSpinner, - ButtonText, -} from "@eight2five/ui/components/button"; -import { VStack } from "@eight2five/ui/components/vstack"; -import { eight2FiveSpacing } from "@eight2five/ui/theme"; - -import { - useAppSettingsSnapshot, - useAppSettingsStore, -} from "../../state/app-settings-store"; -import { - DEVELOPER_MODE_WARNING, - enableDeveloperMode, -} from "./developer-mode-actions"; -import { - SettingsMessage, - SettingsScreenContainer, - SettingsSection, - SettingsValueRow, -} from "./settings-components"; - -export function DeveloperConfirmationScreen() { - const router = useRouter(); - const store = useAppSettingsStore(); - const { status, settings } = useAppSettingsSnapshot(); - const [enabling, setEnabling] = React.useState(false); - const [error, setError] = React.useState(); - - const enable = async () => { - if (enabling || settings.developerModeEnabled) return; - setEnabling(true); - setError(undefined); - try { - await enableDeveloperMode(store); - router.replace("/(tabs)/settings/developer"); - } catch (cause) { - setError(cause instanceof Error ? cause : new Error(String(cause))); - } finally { - setEnabling(false); - } - }; - - return ( - - {error ? ( - {error.message} - ) : null} - - - - - - - - - ); -} +// Compatibility export for stale imports while the old confirmation flow is retired. +export { DeveloperSettingsScreen as DeveloperConfirmationScreen } from "./developer-settings-screen"; diff --git a/apps/mobile/src/features/settings/developer-mode-actions.ts b/apps/mobile/src/features/settings/developer-mode-actions.ts index 8a907a12..3e842006 100644 --- a/apps/mobile/src/features/settings/developer-mode-actions.ts +++ b/apps/mobile/src/features/settings/developer-mode-actions.ts @@ -1,12 +1,13 @@ -import type { AppSettings } from "@eight2five/mobile/settings"; +import { + DEFAULT_APP_SETTINGS, + type AppSettings, + type AppSettingsUpdate, +} from "@eight2five/mobile/settings"; export interface DeveloperModeWriter { - update(partial: { developerModeEnabled: boolean }): Promise; + update(partial: AppSettingsUpdate): Promise; } -export const DEVELOPER_MODE_WARNING = - "Developer controls can modify PANS anchor positions. Incorrect anchor positions can make reported locations inaccurate. These controls are intended for advanced configuration."; - export async function enableDeveloperMode( writer: DeveloperModeWriter, ): Promise { @@ -16,7 +17,12 @@ export async function enableDeveloperMode( export async function disableDeveloperMode( writer: DeveloperModeWriter, ): Promise { - return await writer.update({ developerModeEnabled: false }); + return await writer.update({ + developerModeEnabled: false, + mockLivePositionEnabled: false, + mockLivePositionXSteps: DEFAULT_APP_SETTINGS.mockLivePositionXSteps, + mockLivePositionYSteps: DEFAULT_APP_SETTINGS.mockLivePositionYSteps, + }); } export function canUseDeveloperControls(settings: AppSettings): boolean { diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx index 38c591d3..412e72ae 100644 --- a/apps/mobile/src/features/settings/developer-settings-screen.tsx +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -5,6 +5,7 @@ import { Activity, CircleDotDashed, Code2, + Crosshair, Database, Grid3X3, MapPinned, @@ -39,7 +40,10 @@ import { } from "../../pans/mobile-pans-context"; import { buildDeveloperDiagnosticRows } from "./developer-diagnostics"; import { parseComfortableAnchorRange } from "./comfortable-anchor-range"; -import { disableDeveloperMode } from "./developer-mode-actions"; +import { + disableDeveloperMode, + enableDeveloperMode, +} from "./developer-mode-actions"; import { AnchorNumberInput } from "./standard-anchor-position-form"; import { SettingsMessage, @@ -66,12 +70,22 @@ export function DeveloperSettingsScreen() { const [rssiDraft, setRssiDraft] = React.useState(() => pans.discoveryRssiCutoff.toString(), ); + const [mockXDraft, setMockXDraft] = React.useState(() => + settings.mockLivePositionXSteps.toString(), + ); + const [mockYDraft, setMockYDraft] = React.useState(() => + settings.mockLivePositionYSteps.toString(), + ); const rows = React.useMemo(() => buildDeveloperDiagnosticRows(pans), [pans]); - const disable = async () => { + const setDeveloperMode = async (enabled: boolean) => { setOperationError(undefined); try { - await disableDeveloperMode(settingsStore); + if (enabled) { + await enableDeveloperMode(settingsStore); + } else { + await disableDeveloperMode(settingsStore); + } } catch (cause) { setOperationError( cause instanceof Error ? cause : new Error(String(cause)), @@ -141,6 +155,8 @@ export function DeveloperSettingsScreen() { }; const rangeValidation = parseComfortableAnchorRange(rangeDraft); + const mockX = parseStaticPositionCoordinate(mockXDraft); + const mockY = parseStaticPositionCoordinate(mockYDraft); const parsedRssi = Number(rssiDraft); const validRssi = Number.isInteger(parsedRssi) && parsedRssi >= -100 && parsedRssi <= -30; @@ -154,20 +170,16 @@ export function DeveloperSettingsScreen() { ) : null} - - - router.push("/(tabs)/settings/developer-confirmation") - } - testID="developer-mode-confirmation-link" + title="Developer Settings" + description="Show advanced positioning, field, and PANS configuration controls." + value={false} + onChange={(enabled) => { + if (enabled) void setDeveloperMode(true); + }} + disabled={status !== "ready"} + testID="developer-mode-setting" /> {status === "error" ? ( @@ -213,12 +225,10 @@ export function DeveloperSettingsScreen() { { - if (!enabled) void disable(); - }} + onChange={(enabled) => void setDeveloperMode(enabled)} disabled={status !== "ready"} testID="developer-mode-setting" /> @@ -308,6 +318,72 @@ export function DeveloperSettingsScreen() { /> + + + void settingsStore + .update({ mockLivePositionEnabled }) + .catch((cause) => + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ), + ) + } + testID="mock-live-position-setting" + /> + {settings.mockLivePositionEnabled ? ( + + + + + + ) : null} + + @@ -415,3 +491,17 @@ export function DeveloperSettingsScreen() { ); } + +function parseStaticPositionCoordinate(value: string): { + readonly value?: number; + readonly error?: string; +} { + const normalized = value.trim(); + if (!normalized) return { error: "Enter a coordinate." }; + const parsed = Number(normalized); + if (!Number.isFinite(parsed)) return { error: "Enter a finite number." }; + if (Math.abs(parsed) > 1000) { + return { error: "Enter a value between -1000 and 1000 steps." }; + } + return { value: parsed }; +} diff --git a/apps/mobile/src/features/settings/settings-components.tsx b/apps/mobile/src/features/settings/settings-components.tsx index 21bd0eb3..663baf89 100644 --- a/apps/mobile/src/features/settings/settings-components.tsx +++ b/apps/mobile/src/features/settings/settings-components.tsx @@ -1,12 +1,27 @@ import React from "react"; -import { Host, Picker } from "@expo/ui"; -import { ChevronRight, type LucideIcon } from "lucide-react-native"; +import { + ChevronDown, + ChevronRight, + type LucideIcon, +} from "lucide-react-native"; import { Card } from "@eight2five/ui/components/card"; import { Heading } from "@eight2five/ui/components/heading"; import { HStack } from "@eight2five/ui/components/hstack"; import { Icon } from "@eight2five/ui/components/icon"; import { Pressable } from "@eight2five/ui/components/pressable"; import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { + Select, + SelectBackdrop, + SelectContent, + SelectDragIndicator, + SelectDragIndicatorWrapper, + SelectIcon, + SelectInput, + SelectItem, + SelectPortal, + SelectTrigger, +} from "@eight2five/ui/components/select"; import { Switch } from "@eight2five/ui/components/switch"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; @@ -15,7 +30,6 @@ import { eight2FiveRadii, eight2FiveSpacing, useEight2FiveTheme, - useEight2FiveThemeName, } from "@eight2five/ui/theme"; export function SettingsScreenContainer({ @@ -222,46 +236,68 @@ export function SettingsSelectRow({ testID?: string; }) { const theme = useEight2FiveTheme(); - const themeName = useEight2FiveThemeName(); const selectedLabel = choices.find((choice) => choice.value === value)?.label ?? value; return ( - - + + ); } diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts index 97d3e4cc..b4eb09fe 100644 --- a/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts +++ b/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts @@ -12,8 +12,8 @@ describe("mobile PANS UI selectors", () => { test.each([ ["connected", "Connected", "connected", false], ["scanning", "Searching", "searching", true], - ["connecting", "Connecting", "connecting", true], - ["reconnecting", "Reconnecting", "connecting", true], + ["connecting", "Connecting", "connecting", false], + ["reconnecting", "Reconnecting", "connecting", false], ["disconnected", "Disconnected", "disconnected", false], ["error", "Connection error", "error", false], ] as const)("maps %s status", (state, label, icon, animated) => { diff --git a/apps/mobile/src/pans/mobile-pans-ui.ts b/apps/mobile/src/pans/mobile-pans-ui.ts index 44b33711..afb0c43f 100644 --- a/apps/mobile/src/pans/mobile-pans-ui.ts +++ b/apps/mobile/src/pans/mobile-pans-ui.ts @@ -39,14 +39,14 @@ export function connectionStatusViewModel( label: "Connecting", icon: "connecting", tone: "accent", - animated: true, + animated: false, }; case "reconnecting": return { label: "Reconnecting", icon: "connecting", tone: "accent", - animated: true, + animated: false, }; case "error": return { diff --git a/package-lock.json b/package-lock.json index f0d13ba0..d6f7f2ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,8 +96,8 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "@expo/ui": "~57.0.9", "expo": "~57.0.10", + "expo-blur": "~57.0.2", "expo-dev-client": "~57.0.10", "expo-router": "~57.0.10", "react": "19.2.3", diff --git a/packages/mobile/src/settings/SqliteSettingsRepository.ts b/packages/mobile/src/settings/SqliteSettingsRepository.ts index bbae23eb..35db1be5 100644 --- a/packages/mobile/src/settings/SqliteSettingsRepository.ts +++ b/packages/mobile/src/settings/SqliteSettingsRepository.ts @@ -65,6 +65,9 @@ export class SqliteSettingsRepository implements AppSettingsRepository { distance_green_threshold_steps = ?, distance_yellow_threshold_steps = ?, motion_interpolation_enabled = ?, + mock_live_position_enabled = ?, + mock_live_position_x_steps = ?, + mock_live_position_y_steps = ?, comfortable_anchor_range_meters = ? WHERE singleton_id = ?`, [ @@ -91,6 +94,9 @@ export class SqliteSettingsRepository implements AppSettingsRepository { DEFAULT_APP_SETTINGS.distanceGreenThresholdSteps, DEFAULT_APP_SETTINGS.distanceYellowThresholdSteps, boolToSql(DEFAULT_APP_SETTINGS.motionInterpolationEnabled), + boolToSql(DEFAULT_APP_SETTINGS.mockLivePositionEnabled), + DEFAULT_APP_SETTINGS.mockLivePositionXSteps, + DEFAULT_APP_SETTINGS.mockLivePositionYSteps, DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, 1, ], @@ -124,6 +130,9 @@ export class SqliteSettingsRepository implements AppSettingsRepository { distance_green_threshold_steps, distance_yellow_threshold_steps, motion_interpolation_enabled, + mock_live_position_enabled, + mock_live_position_x_steps, + mock_live_position_y_steps, comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id @@ -161,10 +170,13 @@ export class SqliteSettingsRepository implements AppSettingsRepository { distance_green_threshold_steps, distance_yellow_threshold_steps, motion_interpolation_enabled, + mock_live_position_enabled, + mock_live_position_x_steps, + mock_live_position_y_steps, comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton_id) DO UPDATE SET appearance_mode = excluded.appearance_mode, drill_features_enabled = excluded.drill_features_enabled, @@ -189,6 +201,9 @@ export class SqliteSettingsRepository implements AppSettingsRepository { distance_green_threshold_steps = excluded.distance_green_threshold_steps, distance_yellow_threshold_steps = excluded.distance_yellow_threshold_steps, motion_interpolation_enabled = excluded.motion_interpolation_enabled, + mock_live_position_enabled = excluded.mock_live_position_enabled, + mock_live_position_x_steps = excluded.mock_live_position_x_steps, + mock_live_position_y_steps = excluded.mock_live_position_y_steps, comfortable_anchor_range_meters = excluded.comfortable_anchor_range_meters, active_drill_id = excluded.active_drill_id, selected_drill_page_id = excluded.selected_drill_page_id`, @@ -217,6 +232,9 @@ export class SqliteSettingsRepository implements AppSettingsRepository { normalized.distanceGreenThresholdSteps, normalized.distanceYellowThresholdSteps, boolToSql(normalized.motionInterpolationEnabled), + boolToSql(normalized.mockLivePositionEnabled), + normalized.mockLivePositionXSteps, + normalized.mockLivePositionYSteps, normalized.comfortableAnchorRangeMeters, normalized.activeDrillId, normalized.selectedDrillSetId, @@ -257,6 +275,9 @@ function fromRow(row: AppSettingsRow): AppSettings { distanceGreenThresholdSteps: row.distance_green_threshold_steps, distanceYellowThresholdSteps: row.distance_yellow_threshold_steps, motionInterpolationEnabled: sqliteBoolean(row.motion_interpolation_enabled), + mockLivePositionEnabled: sqliteBoolean(row.mock_live_position_enabled), + mockLivePositionXSteps: row.mock_live_position_x_steps, + mockLivePositionYSteps: row.mock_live_position_y_steps, comfortableAnchorRangeMeters: row.comfortable_anchor_range_meters, activeDrillId: row.active_drill_id, selectedDrillSetId: row.selected_drill_page_id, @@ -296,6 +317,10 @@ function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { settings.distanceYellowThresholdSteps && row.motion_interpolation_enabled === boolToSql(settings.motionInterpolationEnabled) && + row.mock_live_position_enabled === + boolToSql(settings.mockLivePositionEnabled) && + row.mock_live_position_x_steps === settings.mockLivePositionXSteps && + row.mock_live_position_y_steps === settings.mockLivePositionYSteps && row.comfortable_anchor_range_meters === settings.comfortableAnchorRangeMeters && row.active_drill_id === settings.activeDrillId && diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts index 1a9476ab..0269e855 100644 --- a/packages/mobile/src/settings/__tests__/repository.test.ts +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -38,6 +38,9 @@ describe("app settings", () => { distance_green_threshold_steps: 0.5, distance_yellow_threshold_steps: 1, motion_interpolation_enabled: 1, + mock_live_position_enabled: 0, + mock_live_position_x_steps: 0, + mock_live_position_y_steps: 0, comfortable_anchor_range_meters: 20, active_drill_id: null, selected_drill_page_id: null, @@ -69,6 +72,9 @@ describe("app settings", () => { distance_green_threshold_steps: Number.NaN, distance_yellow_threshold_steps: 1, motion_interpolation_enabled: "yes", + mock_live_position_enabled: "yes", + mock_live_position_x_steps: Number.POSITIVE_INFINITY, + mock_live_position_y_steps: Number.NaN, comfortable_anchor_range_meters: Number.NaN, active_drill_id: 17, selected_drill_page_id: "", @@ -184,6 +190,9 @@ describe("app settings", () => { distanceGreenThresholdSteps: 0.75, distanceYellowThresholdSteps: 1.5, motionInterpolationEnabled: false, + mockLivePositionEnabled: true, + mockLivePositionXSteps: 12.5, + mockLivePositionYSteps: 24, comfortableAnchorRangeMeters: 30, }); @@ -195,6 +204,9 @@ describe("app settings", () => { nextTransitionSetCount: 50, distanceGreenThresholdSteps: 0.75, distanceYellowThresholdSteps: 1.5, + mockLivePositionEnabled: true, + mockLivePositionXSteps: 12.5, + mockLivePositionYSteps: 24, activeDrillId: "drill-1", selectedDrillSetId: "set-1", selectedDrillPageId: "set-1", @@ -226,6 +238,9 @@ describe("app settings", () => { distance_green_threshold_steps: 0.75, distance_yellow_threshold_steps: 1.5, motion_interpolation_enabled: 0, + mock_live_position_enabled: 1, + mock_live_position_x_steps: -8, + mock_live_position_y_steps: 16, comfortable_anchor_range_meters: 7, active_drill_id: "drill-1", selected_drill_page_id: "set-2", @@ -389,7 +404,10 @@ class SettingsFakeDatabase { distance_green_threshold_steps: params[20], distance_yellow_threshold_steps: params[21], motion_interpolation_enabled: params[22], - comfortable_anchor_range_meters: params[23], + mock_live_position_enabled: params[23], + mock_live_position_x_steps: params[24], + mock_live_position_y_steps: params[25], + comfortable_anchor_range_meters: params[26], }; } else { this.row = { @@ -416,9 +434,12 @@ class SettingsFakeDatabase { distance_green_threshold_steps: params[21], distance_yellow_threshold_steps: params[22], motion_interpolation_enabled: params[23], - comfortable_anchor_range_meters: params[24], - active_drill_id: params[25], - selected_drill_page_id: params[26], + mock_live_position_enabled: params[24], + mock_live_position_x_steps: params[25], + mock_live_position_y_steps: params[26], + comfortable_anchor_range_meters: params[27], + active_drill_id: params[28], + selected_drill_page_id: params[29], }; } return { lastInsertRowId: 1, changes: 1 }; @@ -452,6 +473,9 @@ function settingsRow(overrides: Record = {}) { distance_green_threshold_steps: 0.5, distance_yellow_threshold_steps: 1, motion_interpolation_enabled: 1, + mock_live_position_enabled: 0, + mock_live_position_x_steps: 0, + mock_live_position_y_steps: 0, comfortable_anchor_range_meters: 20, active_drill_id: null, selected_drill_page_id: null, diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts index 93182324..0ffaafb4 100644 --- a/packages/mobile/src/settings/types.ts +++ b/packages/mobile/src/settings/types.ts @@ -37,6 +37,9 @@ export interface AppSettings { readonly distanceGreenThresholdSteps: number; readonly distanceYellowThresholdSteps: number; readonly motionInterpolationEnabled: boolean; + readonly mockLivePositionEnabled: boolean; + readonly mockLivePositionXSteps: number; + readonly mockLivePositionYSteps: number; readonly comfortableAnchorRangeMeters: number; readonly activeDrillId: string | null; readonly selectedDrillSetId: string | null; @@ -68,6 +71,9 @@ export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ distanceGreenThresholdSteps: DEFAULT_DISTANCE_GREEN_THRESHOLD_STEPS, distanceYellowThresholdSteps: DEFAULT_DISTANCE_YELLOW_THRESHOLD_STEPS, motionInterpolationEnabled: true, + mockLivePositionEnabled: false, + mockLivePositionXSteps: 0, + mockLivePositionYSteps: 0, comfortableAnchorRangeMeters: DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS, activeDrillId: null, selectedDrillSetId: null, @@ -98,6 +104,9 @@ export const APP_PREFERENCE_KEYS = Object.freeze([ "distanceGreenThresholdSteps", "distanceYellowThresholdSteps", "motionInterpolationEnabled", + "mockLivePositionEnabled", + "mockLivePositionXSteps", + "mockLivePositionYSteps", "comfortableAnchorRangeMeters", ] as const satisfies readonly (keyof AppSettings)[]); @@ -213,6 +222,18 @@ export function normalizeAppSettings(value?: unknown): AppSettings { candidate.motionInterpolationEnabled, DEFAULT_APP_SETTINGS.motionInterpolationEnabled, ), + mockLivePositionEnabled: booleanOrDefault( + candidate.mockLivePositionEnabled, + DEFAULT_APP_SETTINGS.mockLivePositionEnabled, + ), + mockLivePositionXSteps: finiteOrDefault( + candidate.mockLivePositionXSteps, + DEFAULT_APP_SETTINGS.mockLivePositionXSteps, + ), + mockLivePositionYSteps: finiteOrDefault( + candidate.mockLivePositionYSteps, + DEFAULT_APP_SETTINGS.mockLivePositionYSteps, + ), comfortableAnchorRangeMeters: positiveFiniteOrDefault( candidate.comfortableAnchorRangeMeters, DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, @@ -241,6 +262,9 @@ export function getEffectiveAppSettings(value: AppSettings): AppSettings { showCachedAnchorGeometry: false, showComfortableAnchorRange: false, showPerimeterStepGrid: false, + mockLivePositionEnabled: false, + mockLivePositionXSteps: DEFAULT_APP_SETTINGS.mockLivePositionXSteps, + mockLivePositionYSteps: DEFAULT_APP_SETTINGS.mockLivePositionYSteps, }; } @@ -289,6 +313,10 @@ function booleanOrDefault(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } +function finiteOrDefault(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + function positiveFiniteOrDefault(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index 7903dada..e977c3cd 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -15,7 +15,7 @@ describe("mobile app SQLite schema preparation", () => { const sql = executed.join("\n"); expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); - expect(MOBILE_SCHEMA_VERSION).toBe(6); + expect(MOBILE_SCHEMA_VERSION).toBe(7); expect(sql).toContain("PRAGMA journal_mode = WAL"); expect(sql).toContain("PRAGMA foreign_keys = OFF"); expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); @@ -57,6 +57,11 @@ describe("mobile app SQLite schema preparation", () => { expect(sql).toContain( "motion_interpolation_enabled INTEGER NOT NULL DEFAULT 1", ); + expect(sql).toContain( + "mock_live_position_enabled INTEGER NOT NULL DEFAULT 0", + ); + expect(sql).toContain("mock_live_position_x_steps REAL NOT NULL DEFAULT 0"); + expect(sql).toContain("mock_live_position_y_steps REAL NOT NULL DEFAULT 0"); expect( sql.indexOf("motion_interpolation_enabled INTEGER NOT NULL"), ).toBeLessThan( diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index 58b8eb76..5abc0dd3 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -10,7 +10,7 @@ export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; * stable, a version mismatch intentionally rebuilds this disposable database * rather than carrying migration code for development-only layouts. */ -export const MOBILE_SCHEMA_VERSION = 6; +export const MOBILE_SCHEMA_VERSION = 7; export const DRILLS_TABLE = "drills"; export const DRILL_SETS_TABLE = "drill_sets"; @@ -206,6 +206,12 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { ), motion_interpolation_enabled INTEGER NOT NULL DEFAULT 1 CHECK (motion_interpolation_enabled IN (0, 1)), + mock_live_position_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (mock_live_position_enabled IN (0, 1)), + mock_live_position_x_steps REAL NOT NULL DEFAULT 0 + CHECK (mock_live_position_x_steps = mock_live_position_x_steps), + mock_live_position_y_steps REAL NOT NULL DEFAULT 0 + CHECK (mock_live_position_y_steps = mock_live_position_y_steps), comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 CHECK (comfortable_anchor_range_meters > 0), active_drill_id TEXT diff --git a/packages/ui/components/modal/index.tsx b/packages/ui/components/modal/index.tsx index fc91f695..f8c1c229 100644 --- a/packages/ui/components/modal/index.tsx +++ b/packages/ui/components/modal/index.tsx @@ -47,7 +47,7 @@ const modalBackdropStyle = tva({ }); const modalContentStyle = tva({ - base: 'bg-background rounded-md overflow-hidden border border-border/80 shadow-hard-2 p-6', + base: 'bg-background rounded-2xl overflow-hidden border border-border/80 shadow-hard-2 p-6', parentVariants: { size: { xs: 'w-[60%] max-w-[360px]', @@ -128,7 +128,7 @@ const ModalBackdrop = React.forwardRef< const ModalContent = React.forwardRef< React.ComponentRef, IModalContentProps ->(function ModalContent({ className, size, ...props }, ref) { +>(function ModalContent({ className, size, style, ...props }, ref) { const { size: parentSize } = useStyleContext(SCOPE); return ( @@ -139,6 +139,7 @@ const ModalContent = React.forwardRef< })} exiting={FadeOut.duration(200)} {...props} + style={[{ borderCurve: 'continuous' }, style]} className={modalContentStyle({ parentVariants: { size: parentSize, From 32e94bf433a67ebcc70f5628d0717d82fd512792 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 01:59:40 -0500 Subject: [PATCH 086/101] fix(mobile): Repair field controls and transition rendering --- apps/mobile/app/_layout.tsx | 9 +- .../field/__tests__/field-hud-state.test.ts | 13 +- .../__tests__/live-position-hud-state.test.ts | 4 +- .../__tests__/drill-pill-presentation.test.ts | 8 +- .../drill-pill/animated-value-switch.tsx | 16 +- .../drill-pill/drill-pill-presentation.ts | 4 - .../field/drill-pill/drill-set-list.tsx | 14 +- .../drill-pill/drill-set-metric-grid.tsx | 215 +++++++++++------- .../src/features/field/field-hud-state.ts | 11 +- .../src/features/field/field-screen.tsx | 11 +- .../features/field/live-position-hud-state.ts | 3 +- .../src/features/field/live-position-hud.tsx | 2 - .../field/page-dial/page-dial-canvas.tsx | 1 + .../field/page-dial/page-dial-controls.tsx | 34 +-- .../features/field/page-dial/page-dial.tsx | 55 ++++- .../field/use-field-screen-controller.ts | 15 ++ .../src/features/settings/settings-screen.tsx | 48 ++-- .../src/drill/__tests__/render-scene.test.ts | 1 + .../drill/__tests__/transition-scene.test.ts | 22 ++ packages/mobile/src/drill/render-scene.ts | 13 ++ packages/mobile/src/drill/transition-scene.ts | 63 +++++ .../field/__tests__/field-camera-math.test.ts | 24 ++ .../src/field/__tests__/guidance.test.ts | 9 + .../src/field/__tests__/marching.test.ts | 8 + .../src/field/camera/field-camera-math.ts | 50 ++-- .../src/field/camera/field-camera-types.ts | 2 + .../src/field/camera/use-field-gestures.ts | 6 + packages/mobile/src/field/guidance.ts | 5 +- packages/mobile/src/field/marching.ts | 4 +- .../mobile/src/field/render/field-canvas.tsx | 5 + .../src/field/render/field-drill-layer.tsx | 57 ++++- .../mobile/src/field/render/field-scene.tsx | 23 +- .../src/field/render/page-dial-canvas.tsx | 20 +- .../src/settings/SqliteSettingsRepository.ts | 10 +- .../src/settings/__tests__/repository.test.ts | 81 ++++--- packages/mobile/src/settings/types.ts | 11 +- .../storage/__tests__/mobileDatabase.test.ts | 5 +- packages/mobile/src/storage/mobileDatabase.ts | 8 +- 38 files changed, 622 insertions(+), 268 deletions(-) diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 28650fdd..df25ba90 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -40,11 +40,12 @@ export default function MobileRootLayout() { - - + {/* Keep PANS above the UI portal host so modal content retains PANS context. */} + + - - + + diff --git a/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts index 42c0e6a3..fb019b46 100644 --- a/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts +++ b/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts @@ -20,21 +20,14 @@ const set = (overrides: Partial = {}): DrillSet => ({ }); describe("field HUD state", () => { - test("keeps count display and expansion as explicit session state", () => { - const measures = reduceFieldHudState(INITIAL_FIELD_HUD_STATE, { - type: "toggle-count-display", - }); - expect(measures).toEqual({ - countDisplayMode: "measures", - drillPillExpanded: false, - }); - const expanded = reduceFieldHudState(measures, { + test("keeps expansion as explicit session state", () => { + const expanded = reduceFieldHudState(INITIAL_FIELD_HUD_STATE, { type: "toggle-drill-pill", }); expect(expanded.drillPillExpanded).toBe(true); expect( reduceFieldHudState(expanded, { type: "collapse-drill-pill" }), - ).toEqual({ countDisplayMode: "measures", drillPillExpanded: false }); + ).toEqual({ drillPillExpanded: false }); }); test("formats counts, measures, metrics, terminology, and coordinates centrally", () => { diff --git a/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts index fc8c6b84..e2bbd79e 100644 --- a/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts +++ b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts @@ -24,8 +24,8 @@ describe("live position HUD state", () => { target: { xMeters: 0.5715, yMeters: 0 }, greenThresholdSteps: 0.5, yellowThresholdSteps: 1, - }).tone, - ).toBe("warning"); + }), + ).toMatchObject({ value: "one step", tone: "warning" }); expect( getTargetDistancePresentation({ live, diff --git a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts index a9905bd9..408b5890 100644 --- a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts +++ b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts @@ -29,8 +29,8 @@ describe("drill pill metric modes", () => { expect( rows.map((row) => getCountMetricPresentation(row, "measures")), ).toMatchObject([ - { key: "measures", label: "Measures", value: "1", direction: 1 }, - { key: "measures", label: "Measures", value: "2–3", direction: 1 }, + { key: "measures", label: "Measures", value: "1" }, + { key: "measures", label: "Measures", value: "2–3" }, ]); }); @@ -40,8 +40,8 @@ describe("drill pill metric modes", () => { getTransitionMetricPresentation(row, "crossing-counts"), ), ).toMatchObject([ - { key: "crossing-counts", label: "xCounts", direction: 1 }, - { key: "crossing-counts", label: "xCounts", direction: 1 }, + { key: "crossing-counts", label: "xCounts" }, + { key: "crossing-counts", label: "xCounts" }, ]); }); }); diff --git a/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx index 7464a3da..67c67ab1 100644 --- a/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx +++ b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx @@ -1,32 +1,24 @@ import React from "react"; import { View, type StyleProp, type ViewStyle } from "react-native"; import Animated, { - FadeInDown, - FadeInUp, - FadeOutDown, - FadeOutUp, + FadeIn, + FadeOut, ReduceMotion, } from "react-native-reanimated"; export function AnimatedValueSwitch({ displayKey, - direction, children, style, testID, }: { readonly displayKey: string; - readonly direction: -1 | 1; readonly children: React.ReactNode; readonly style?: StyleProp; readonly testID?: string; }) { - const entering = (direction > 0 ? FadeInUp : FadeInDown) - .duration(180) - .reduceMotion(ReduceMotion.System); - const exiting = (direction > 0 ? FadeOutUp : FadeOutDown) - .duration(180) - .reduceMotion(ReduceMotion.System); + const entering = FadeIn.duration(160).reduceMotion(ReduceMotion.System); + const exiting = FadeOut.duration(160).reduceMotion(ReduceMotion.System); return ( diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts index a1cc2bad..d1227933 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts +++ b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts @@ -7,7 +7,6 @@ import type { export interface AnimatedMetricPresentation { readonly key: string; - readonly direction: -1 | 1; readonly label: string; readonly value: string; } @@ -19,13 +18,11 @@ export function getCountMetricPresentation( return mode === "counts" ? { key: mode, - direction: -1, label: "Counts", value: presentation.counts, } : { key: mode, - direction: 1, label: "Measures", value: presentation.measures, }; @@ -37,7 +34,6 @@ export function getTransitionMetricPresentation( ): AnimatedMetricPresentation { return { key: mode, - direction: mode === "step-size" ? -1 : 1, label: presentation.metricLabel, value: presentation.metric, }; diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx index 07a5c843..f68ec2ba 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx @@ -40,16 +40,12 @@ export function DrillSetList({ const listRef = React.useRef>(null); React.useEffect(() => { - if (!expanded || selectedIndex < 0 || pages.length === 0) return; + if (!expanded || pages.length === 0) return; const frame = requestAnimationFrame(() => { - listRef.current?.scrollToIndex({ - index: selectedIndex, - animated: false, - viewPosition: 0.5, - }); + listRef.current?.scrollToOffset({ offset: 0, animated: false }); }); return () => cancelAnimationFrame(frame); - }, [expanded, pages.length, selectedIndex]); + }, [expanded, pages.length]); const renderItem = React.useCallback( ({ item, index }: ListRenderItemInfo) => { @@ -64,7 +60,9 @@ export function DrillSetList({ return ( onSelectIndex(index)} style={{ diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx index e3e46b3e..f90b5b74 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx @@ -1,6 +1,12 @@ import React from "react"; import { ChevronDown, ChevronUp } from "lucide-react-native"; -import { Box } from "@eight2five/ui/components/box"; +import Animated, { + interpolateColor, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { HStack } from "@eight2five/ui/components/hstack"; import { Icon } from "@eight2five/ui/components/icon"; import { Pressable } from "@eight2five/ui/components/pressable"; @@ -74,24 +80,19 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ ? `Show ${countDisplayMode === "counts" ? "measures" : "counts"}` : undefined } - disabled={!onToggleCounts} pointerEvents={onToggleCounts ? "auto" : "none"} onPress={onToggleCounts} style={{ width: columns.countWidth }} > - - - + /> - - - + /> + + + ); +} + +function SwitchingMetricCell({ + displayKey, + label, + value, + labelColor, + valueColor, + modeIndex, + testID, +}: { + readonly displayKey: string; + readonly label: string; + readonly value: string; + readonly labelColor: string; + readonly valueColor: string; + readonly modeIndex: 0 | 1; + readonly testID?: string; +}) { + return ( + + + + + + + ); +} + +function ModeIndicator({ + modeIndex, + labelColor, + valueColor, +}: { + readonly modeIndex: 0 | 1; + readonly labelColor: string; + readonly valueColor: string; +}) { + const progress = useSharedValue(modeIndex); + React.useEffect(() => { + progress.value = withTiming(modeIndex, { + duration: 180, + reduceMotion: ReduceMotion.System, + }); + }, [modeIndex, progress]); + + const topStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + progress.value, + [0, 1], + [valueColor, labelColor], + ), + opacity: 1 - progress.value * 0.5, + })); + const bottomStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + progress.value, + [0, 1], + [labelColor, valueColor], + ), + opacity: 0.5 + progress.value * 0.5, + })); + const dotStyle = { + width: 5, + height: 5, + borderRadius: 2.5, + } as const; + + return ( + + + + + ); +} + +function MetricText({ + label, + value, + labelColor, + valueColor, +}: { + readonly label: string; + readonly value: string; + readonly labelColor: string; + readonly valueColor: string; +}) { + return ( + ); - - if (modeIndex === undefined) { - return ( - - {content} - - ); - } - - return ( - - - - - - {content} - - ); } diff --git a/apps/mobile/src/features/field/field-hud-state.ts b/apps/mobile/src/features/field/field-hud-state.ts index a6a1dad2..ccc85924 100644 --- a/apps/mobile/src/features/field/field-hud-state.ts +++ b/apps/mobile/src/features/field/field-hud-state.ts @@ -14,20 +14,17 @@ import type { TransitionMetricMode } from "@eight2five/mobile/settings"; import { getTransitionPresentation } from "../drill/transition-presentation"; -export type CountDisplayMode = "counts" | "measures"; +export type { CountDisplayMode } from "@eight2five/mobile/settings"; export interface FieldHudState { - readonly countDisplayMode: CountDisplayMode; readonly drillPillExpanded: boolean; } export type FieldHudAction = - | { readonly type: "toggle-count-display" } | { readonly type: "toggle-drill-pill" } | { readonly type: "collapse-drill-pill" }; export const INITIAL_FIELD_HUD_STATE: FieldHudState = Object.freeze({ - countDisplayMode: "counts", drillPillExpanded: false, }); @@ -36,12 +33,6 @@ export function reduceFieldHudState( action: FieldHudAction, ): FieldHudState { switch (action.type) { - case "toggle-count-display": - return { - ...state, - countDisplayMode: - state.countDisplayMode === "counts" ? "measures" : "counts", - }; case "toggle-drill-pill": return { ...state, drillPillExpanded: !state.drillPillExpanded }; case "collapse-drill-pill": diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx index 14a90529..cb2bb76d 100644 --- a/apps/mobile/src/features/field/field-screen.tsx +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -187,6 +187,7 @@ export function FieldScreen({ onViewportChange={controller.commitViewport} palette={palette} fieldPreset={controller.fieldPreset} + perspective={controller.settings.fieldPerspective} livePosition={livePositionValue} targetPosition={targetPosition} drillScene={drillScene} @@ -211,15 +212,13 @@ export function FieldScreen({ pages={controller.pages} selectedIndex={controller.selectedIndex} terminology={controller.settings.drillTerminology} - countDisplayMode={hudState.countDisplayMode} + countDisplayMode={controller.settings.countDisplayMode} metricMode={controller.settings.transitionMetricMode} fieldPreset={controller.fieldPreset} expanded={canExpandDrillPill && hudState.drillPillExpanded} controlsDisabled={controlsDisabled} error={controller.error} - onToggleCounts={() => - dispatchHud({ type: "toggle-count-display" }) - } + onToggleCounts={() => void controller.toggleCountDisplayMode()} onToggleMetric={() => void controller.toggleMetricMode()} onToggleExpanded={ canExpandDrillPill @@ -301,7 +300,9 @@ export function FieldScreen({ }} /> diff --git a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx index 52d53c58..67bd19fd 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx @@ -40,6 +40,7 @@ export function PageDialCanvas({ innerColor={innerColor} backgroundColor={backgroundColor} knobColor={knobColor} + showKnob={false} dividerSegments={[]} /> ); diff --git a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx index 98ebf296..65378ccf 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx @@ -52,11 +52,6 @@ export function PageDialControls({ proportions.controlCenterOffset, ); const centerDiameter = proportions.centerDiskDiameter; - const dividerSegments = getPageDialDividerSegments( - diameter, - proportions.innerDiskDiameter, - centerDiameter, - ); const buttonStyle = (x: number, y: number, disabled = false) => ({ position: "absolute" as const, left: x - buttonSize / 2, @@ -70,14 +65,6 @@ export function PageDialControls({ return ( <> - {dividerSegments.map((segment, index) => ( - - ))} + {dividerSegments.map((segment, index) => ( + + ))} + + ); +} + function getDividerStyle(segment: PageDialLineSegment): ViewStyle { const dx = segment.end.x - segment.start.x; const dy = segment.end.y - segment.start.y; diff --git a/apps/mobile/src/features/field/page-dial/page-dial.tsx b/apps/mobile/src/features/field/page-dial/page-dial.tsx index 429190ac..909c8aad 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial.tsx @@ -1,7 +1,8 @@ import React from "react"; import { View } from "react-native"; import { GestureDetector } from "react-native-gesture-handler"; -import { +import Animated, { + useAnimatedStyle, useSharedValue, withTiming, type SharedValue, @@ -15,9 +16,13 @@ import { import { FrostedFieldSurface } from "../field-frosted-surface"; import { PageDialCanvas } from "./page-dial-canvas"; -import { PageDialControls } from "./page-dial-controls"; +import { PageDialControls, PageDialDividers } from "./page-dial-controls"; import { triggerPageDialHaptic, usePageDialGesture } from "./page-dial-gesture"; -import { normalizePageIndex } from "./page-dial-math"; +import { + normalizePageIndex, + PAGE_DIAL_KNOB_DIAMETER_RATIO, + pageDialPointForProgress, +} from "./page-dial-math"; function animateProgress( sharedValue: SharedValue, @@ -123,6 +128,12 @@ export function PageDial({ backgroundColor={resolvedBackgroundColor} knobColor={theme.raw.white} /> + + ; + readonly color: string; +}) { + const knobDiameter = diameter * PAGE_DIAL_KNOB_DIAMETER_RATIO; + const animatedStyle = useAnimatedStyle(() => { + const point = pageDialPointForProgress(progress.value, diameter); + return { + left: point.x - knobDiameter / 2, + top: point.y - knobDiameter / 2, + }; + }); + return ( + + ); +} + function colorWithOpacity(color: string, opacity: number): string { const match = /^#([0-9a-f]{6})$/i.exec(color); if (!match) return color; diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts index 6e27d6f5..501bc703 100644 --- a/apps/mobile/src/features/field/use-field-screen-controller.ts +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -128,6 +128,20 @@ export function useFieldScreenController() { } }, [snapshot.settings.transitionMetricMode, snapshot.status, store]); + const toggleCountDisplayMode = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + try { + await store.update({ + countDisplayMode: + snapshot.settings.countDisplayMode === "counts" + ? "measures" + : "counts", + }); + } catch (cause) { + setFieldError(cause instanceof Error ? cause : new Error(String(cause))); + } + }, [snapshot.settings.countDisplayMode, snapshot.status, store]); + const selectPageAtIndex = React.useCallback( async (index: number) => { const page = pages[index]; @@ -258,6 +272,7 @@ export function useFieldScreenController() { error: fieldError ?? snapshot.error, selectActiveDrill, toggleMetricMode, + toggleCountDisplayMode, selectPageAtIndex, selectPerformer, refreshDrills, diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 6840d7d5..2d855fe6 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -18,7 +18,6 @@ import type { AppearanceMode, AppSettingsUpdate, FieldPerspective, - TransitionMetricMode, } from "@eight2five/mobile/settings"; import type { DrillTerminology } from "@eight2five/mobile/drill"; import { @@ -67,12 +66,7 @@ const FIELD_PRESET_CHOICES = FIELD_PRESET_IDS.map((value) => ({ value, })) satisfies readonly { label: string; value: FieldPresetId }[]; -const TRANSITION_CHOICES = [ - { label: "Step Size", value: "step-size" }, - { label: "xCounts", value: "crossing-counts" }, -] as const; - -const TRANSITION_COUNT_CHOICES = Array.from({ length: 51 }, (_, count) => ({ +const TRANSITION_COUNT_CHOICES = Array.from({ length: 6 }, (_, count) => ({ label: String(count), value: String(count), })); @@ -81,7 +75,7 @@ const DISTANCE_THRESHOLD_VALUES = [0, 0.25, 0.5, 0.75, 1, 1.5, 2, 3]; function distanceThresholdChoices(values: readonly number[]) { return values.map((value) => ({ - label: `${value} steps`, + label: value === 1 ? "one step" : `${value} steps`, value: String(value), })); } @@ -124,19 +118,6 @@ export function SettingsScreen() { ) : null} - - - icon={Palette} - title="App appearance" - description="Follow the system appearance or always use a light or dark theme." - value={settings.appearanceMode} - choices={APPEARANCE_CHOICES} - onChange={(appearanceMode) => void update({ appearanceMode })} - disabled={disabled} - testID="appearance-mode-setting" - /> - - + + + icon={Palette} + title="App appearance" + description="Follow the system appearance or always use a light or dark theme." + value={settings.appearanceMode} + choices={APPEARANCE_CHOICES} + onChange={(appearanceMode) => void update({ appearanceMode })} + disabled={disabled} + testID="appearance-mode-setting" + /> + + - - icon={Route} - title="Step size metric" - description="Show Step Size or xCounts." - value={settings.transitionMetricMode} - choices={TRANSITION_CHOICES} - onChange={(transitionMetricMode) => - void update({ transitionMetricMode }) - } - disabled={disabled} - testID="transition-metric-setting" - /> icon={RulerDimensionLine} title="Green distance threshold" diff --git a/packages/mobile/src/drill/__tests__/render-scene.test.ts b/packages/mobile/src/drill/__tests__/render-scene.test.ts index 75abaf11..2be4b6cc 100644 --- a/packages/mobile/src/drill/__tests__/render-scene.test.ts +++ b/packages/mobile/src/drill/__tests__/render-scene.test.ts @@ -362,6 +362,7 @@ describe("selected-set drill render scene", () => { "static", "anchors", "entities", + "extra-connectors", "extra-dots", "previous", "next", diff --git a/packages/mobile/src/drill/__tests__/transition-scene.test.ts b/packages/mobile/src/drill/__tests__/transition-scene.test.ts index 26736405..ef683a97 100644 --- a/packages/mobile/src/drill/__tests__/transition-scene.test.ts +++ b/packages/mobile/src/drill/__tests__/transition-scene.test.ts @@ -292,8 +292,18 @@ describe("transition scene marker state", () => { ); expect(scene.previous?.fromSetId).toBe(2); + expect( + scene.previousConnectors.map(({ fromSetId, toSetId }) => [ + fromSetId, + toSetId, + ]), + ).toEqual([ + [1, 2], + [0, 1], + ]); expect(scene.previousDots.map((dot) => dot.setId)).toEqual([1, 0]); expect(scene.next?.toSetId).toBe(4); + expect(scene.nextConnectors).toEqual([]); }); test("uses total-count windows without backfilling a suppressed immediate marker", () => { @@ -352,6 +362,18 @@ describe("transition scene marker state", () => { expect(scene.previous).toBeDefined(); expect(scene.next).toBeDefined(); + expect( + scene.previousConnectors.map(({ fromSetId, toSetId }) => [ + fromSetId, + toSetId, + ]), + ).toEqual([[0, 1]]); + expect( + scene.nextConnectors.map(({ fromSetId, toSetId }) => [ + fromSetId, + toSetId, + ]), + ).toEqual([[3, 4]]); expect(scene.previousDots).toEqual([ { setId: 0, point: { xSteps: 8, ySteps: 0 } }, ]); diff --git a/packages/mobile/src/drill/render-scene.ts b/packages/mobile/src/drill/render-scene.ts index 083cd3c4..fb7ac18a 100644 --- a/packages/mobile/src/drill/render-scene.ts +++ b/packages/mobile/src/drill/render-scene.ts @@ -140,6 +140,8 @@ export interface DrillRenderScene { readonly entities: readonly DrillRenderEntity[]; readonly previous?: PhysicalImmediateTransition; readonly next?: PhysicalImmediateTransition; + readonly previousConnectors: readonly PhysicalImmediateTransition[]; + readonly nextConnectors: readonly PhysicalImmediateTransition[]; readonly previousDots: readonly PhysicalTransitionDot[]; readonly nextDots: readonly PhysicalTransitionDot[]; } @@ -152,6 +154,7 @@ export const DRILL_RENDER_LAYER_ORDER = Object.freeze([ "static", "anchors", "entities", + "extra-connectors", "extra-dots", "previous", "next", @@ -236,6 +239,12 @@ export function buildDrillRenderScene( ), } : {}), + previousConnectors: transitionScene.previousConnectors.map((transition) => + projectImmediateTransition(transition, field, input.geometryOptions), + ), + nextConnectors: transitionScene.nextConnectors.map((transition) => + projectImmediateTransition(transition, field, input.geometryOptions), + ), previousDots: transitionScene.previousDots.map((dot) => projectTransitionDot(dot, field), ), @@ -451,6 +460,8 @@ function freezeScene(scene: { readonly entities: readonly DrillRenderEntity[]; readonly previous?: PhysicalImmediateTransition; readonly next?: PhysicalImmediateTransition; + readonly previousConnectors: readonly PhysicalImmediateTransition[]; + readonly nextConnectors: readonly PhysicalImmediateTransition[]; readonly previousDots: readonly PhysicalTransitionDot[]; readonly nextDots: readonly PhysicalTransitionDot[]; }): DrillRenderScene { @@ -460,6 +471,8 @@ function freezeScene(scene: { entities: Object.freeze( scene.entities.map((entity) => Object.freeze(entity)), ), + previousConnectors: Object.freeze([...scene.previousConnectors]), + nextConnectors: Object.freeze([...scene.nextConnectors]), previousDots: Object.freeze([...scene.previousDots]), nextDots: Object.freeze([...scene.nextDots]), }); diff --git a/packages/mobile/src/drill/transition-scene.ts b/packages/mobile/src/drill/transition-scene.ts index 74afa414..94fcfe94 100644 --- a/packages/mobile/src/drill/transition-scene.ts +++ b/packages/mobile/src/drill/transition-scene.ts @@ -64,6 +64,9 @@ export interface TransitionScene { readonly current: DrillGridPoint | null; readonly previous?: ImmediateTransition; readonly next?: ImmediateTransition; + /** Depth > 1 connectors are ordered nearest-to-farthest from the selected set. */ + readonly previousConnectors: readonly ImmediateTransition[]; + readonly nextConnectors: readonly ImmediateTransition[]; /** Extra dots are ordered nearest-to-farthest from the selected set. */ readonly previousDots: readonly TransitionDot[]; readonly nextDots: readonly TransitionDot[]; @@ -121,6 +124,8 @@ export function buildTransitionScene( selectedPerformerEntityId: input.selectedPerformerEntityId, selectedSourceSetId: input.selectedSourceSetId, current, + previousConnectors: [], + nextConnectors: [], previousDots: [], nextDots: [], }; @@ -171,6 +176,14 @@ export function buildTransitionScene( epsilon, ); + const extraConnectors = createExtraTransitionConnectors( + input.document, + input.selectedPerformerEntityId, + previousIndices, + nextIndices, + input.geometryOptions, + epsilon, + ); const extraDots = createSceneExtraDots( input.document, input.selectedPerformerEntityId, @@ -186,6 +199,8 @@ export function buildTransitionScene( ...emptyScene, ...(previous ? { previous } : {}), ...(next ? { next } : {}), + previousConnectors: extraConnectors.previousConnectors, + nextConnectors: extraConnectors.nextConnectors, previousDots: extraDots.previousDots, nextDots: extraDots.nextDots, }; @@ -248,6 +263,54 @@ function makeImmediateTransition( }; } +function createExtraTransitionConnectors( + document: DrillDocument, + entityId: number, + previousIndices: readonly number[], + nextIndices: readonly number[], + geometryOptions: TransitionGeometryOptions | undefined, + epsilon: number, +): { + readonly previousConnectors: readonly ImmediateTransition[]; + readonly nextConnectors: readonly ImmediateTransition[]; +} { + const createChain = ( + indices: readonly number[], + direction: "previous" | "next", + ): readonly ImmediateTransition[] => { + const connectors: ImmediateTransition[] = []; + for (let depth = 1; depth < indices.length; depth += 1) { + const nearer = positionAtIndex(document, entityId, indices[depth - 1]); + const farther = positionAtIndex(document, entityId, indices[depth]); + const transition = + direction === "previous" + ? createImmediateTransition( + document, + entityId, + farther, + nearer, + geometryOptions, + epsilon, + ) + : createImmediateTransition( + document, + entityId, + nearer, + farther, + geometryOptions, + epsilon, + ); + if (transition) connectors.push(transition); + } + return connectors; + }; + + return { + previousConnectors: createChain(previousIndices, "previous"), + nextConnectors: createChain(nextIndices, "next"), + }; +} + function createSceneExtraDots( document: DrillDocument, entityId: number, diff --git a/packages/mobile/src/field/__tests__/field-camera-math.test.ts b/packages/mobile/src/field/__tests__/field-camera-math.test.ts index ee4d5728..2af873f9 100644 --- a/packages/mobile/src/field/__tests__/field-camera-math.test.ts +++ b/packages/mobile/src/field/__tests__/field-camera-math.test.ts @@ -39,6 +39,30 @@ describe("field camera math", () => { ).toEqual(screen); }); + test("rotates performer perspective by 180 degrees and round-trips it", () => { + const point = { xMeters: 49.25, yMeters: 18.5 }; + const director = fieldWorldToScreen(point, viewport, size, "director"); + const performer = fieldWorldToScreen(point, viewport, size, "performer"); + + expect(performer.x - size.width / 2).toBeCloseTo( + -(director.x - size.width / 2), + 10, + ); + expect(performer.y - size.height / 2).toBeCloseTo( + -(director.y - size.height / 2), + 10, + ); + expect(fieldScreenToWorld(performer, viewport, size, "performer")).toEqual( + point, + ); + expect( + applyFieldCameraTransform( + point, + fieldCameraTransform(viewport, size, "performer"), + ), + ).toEqual(performer); + }); + test("preserves the world point beneath a pinch focal point", () => { const focal = { x: 155, y: 92 }; const world = fieldScreenToWorld(focal, viewport, size); diff --git a/packages/mobile/src/field/__tests__/guidance.test.ts b/packages/mobile/src/field/__tests__/guidance.test.ts index c96e2598..5cc60c08 100644 --- a/packages/mobile/src/field/__tests__/guidance.test.ts +++ b/packages/mobile/src/field/__tests__/guidance.test.ts @@ -23,6 +23,15 @@ describe("field guidance", () => { expect(guidance.yLabel).toBe("3 steps toward the back sideline"); }); + test("spells a singular displacement as one step", () => { + const guidance = calculateFieldGuidance( + drillGridPointToFieldPoint({ xSteps: 0, ySteps: 0 }), + drillGridPointToFieldPoint({ xSteps: 1, ySteps: 1 }), + ); + expect(guidance.xLabel).toBe("one step toward Side 2"); + expect(guidance.yLabel).toBe("one step toward the back sideline"); + }); + test("uses front-sideline wording for negative Y and no phone heading", () => { const guidance = calculateFieldGuidance( drillGridPointToFieldPoint({ xSteps: 0, ySteps: 3 }), diff --git a/packages/mobile/src/field/__tests__/marching.test.ts b/packages/mobile/src/field/__tests__/marching.test.ts index b0ad6633..023cfbca 100644 --- a/packages/mobile/src/field/__tests__/marching.test.ts +++ b/packages/mobile/src/field/__tests__/marching.test.ts @@ -89,6 +89,14 @@ describe("marching coordinate conversion", () => { }, ); + test("spells a singular marching step as one step", () => { + expect( + formatMarchingFrontBack( + fieldPointToMarchingCoordinate(gridPoint(0, 1)).frontBack, + ), + ).toBe("One Step behind Front Sideline"); + }); + test("keeps canonical fractional values while formatting quarter steps", () => { const coordinate = fieldPointToMarchingCoordinate( gridPoint(-24 + 1.249999999, 28 + 2.500000001), diff --git a/packages/mobile/src/field/camera/field-camera-math.ts b/packages/mobile/src/field/camera/field-camera-math.ts index 23fbc205..5f37f901 100644 --- a/packages/mobile/src/field/camera/field-camera-math.ts +++ b/packages/mobile/src/field/camera/field-camera-math.ts @@ -2,6 +2,7 @@ import type { FieldPoint } from "../types"; import type { FieldCamera, FieldCameraBounds, + FieldCameraPerspective, FieldPanBaseline, FieldViewport, FieldViewportSize, @@ -21,15 +22,20 @@ export function fieldWorldToScreen( point: FieldPoint, viewport: FieldViewport, size: FieldViewportSize, + perspective: FieldCameraPerspective = "director", ): { x: number; y: number } { "worklet"; + const xSign = perspective === "performer" ? -1 : 1; + const ySign = perspective === "performer" ? 1 : -1; return { x: size.width / 2 + - (point.xMeters - viewport.centerXMeters) / viewport.metersPerPixel, + ((point.xMeters - viewport.centerXMeters) / viewport.metersPerPixel) * + xSign, y: - size.height / 2 - - (point.yMeters - viewport.centerYMeters) / viewport.metersPerPixel, + size.height / 2 + + ((point.yMeters - viewport.centerYMeters) / viewport.metersPerPixel) * + ySign, }; } @@ -37,15 +43,18 @@ export function fieldScreenToWorld( point: { readonly x: number; readonly y: number }, viewport: FieldViewport, size: FieldViewportSize, + perspective: FieldCameraPerspective = "director", ): FieldPoint { "worklet"; + const xSign = perspective === "performer" ? -1 : 1; + const ySign = perspective === "performer" ? 1 : -1; return { xMeters: viewport.centerXMeters + - (point.x - size.width / 2) * viewport.metersPerPixel, + (point.x - size.width / 2) * viewport.metersPerPixel * xSign, yMeters: - viewport.centerYMeters - - (point.y - size.height / 2) * viewport.metersPerPixel, + viewport.centerYMeters + + (point.y - size.height / 2) * viewport.metersPerPixel * ySign, }; } @@ -64,15 +73,18 @@ export function fieldPanCenter( baseline: FieldPanBaseline, translationX: number, translationY: number, + perspective: FieldCameraPerspective = "director", ): FieldPoint { "worklet"; + const xSign = perspective === "performer" ? -1 : 1; + const ySign = perspective === "performer" ? 1 : -1; return { xMeters: baseline.center.xMeters - - (translationX - baseline.translationX) * baseline.metersPerPixel, + (translationX - baseline.translationX) * baseline.metersPerPixel * xSign, yMeters: - baseline.center.yMeters + - (translationY - baseline.translationY) * baseline.metersPerPixel, + baseline.center.yMeters - + (translationY - baseline.translationY) * baseline.metersPerPixel * ySign, }; } @@ -81,13 +93,18 @@ export function fieldCenterForStationaryWorldPoint( screenPoint: { readonly x: number; readonly y: number }, size: FieldViewportSize, metersPerPixel: number, + perspective: FieldCameraPerspective = "director", ): FieldPoint { "worklet"; + const xSign = perspective === "performer" ? -1 : 1; + const ySign = perspective === "performer" ? 1 : -1; return { xMeters: - worldPoint.xMeters - (screenPoint.x - size.width / 2) * metersPerPixel, + worldPoint.xMeters - + (screenPoint.x - size.width / 2) * metersPerPixel * xSign, yMeters: - worldPoint.yMeters + (screenPoint.y - size.height / 2) * metersPerPixel, + worldPoint.yMeters - + (screenPoint.y - size.height / 2) * metersPerPixel * ySign, }; } @@ -139,14 +156,17 @@ export interface FieldCameraTransform { export function fieldCameraTransform( viewport: FieldViewport, size: FieldViewportSize, + perspective: FieldCameraPerspective = "director", ): FieldCameraTransform { "worklet"; const scale = 1 / viewport.metersPerPixel; + const scaleX = scale * (perspective === "performer" ? -1 : 1); + const scaleY = scale * (perspective === "performer" ? 1 : -1); return { - scaleX: scale, - scaleY: -scale, - translateX: size.width / 2 - viewport.centerXMeters * scale, - translateY: size.height / 2 + viewport.centerYMeters * scale, + scaleX, + scaleY, + translateX: size.width / 2 - viewport.centerXMeters * scaleX, + translateY: size.height / 2 - viewport.centerYMeters * scaleY, }; } diff --git a/packages/mobile/src/field/camera/field-camera-types.ts b/packages/mobile/src/field/camera/field-camera-types.ts index 2c8eb078..9408048d 100644 --- a/packages/mobile/src/field/camera/field-camera-types.ts +++ b/packages/mobile/src/field/camera/field-camera-types.ts @@ -2,6 +2,8 @@ import type { SharedValue } from "react-native-reanimated"; import type { FieldPoint } from "../types"; +export type FieldCameraPerspective = "director" | "performer"; + export interface FieldViewportSize { readonly width: number; readonly height: number; diff --git a/packages/mobile/src/field/camera/use-field-gestures.ts b/packages/mobile/src/field/camera/use-field-gestures.ts index e5be60f5..bfe7e6f9 100644 --- a/packages/mobile/src/field/camera/use-field-gestures.ts +++ b/packages/mobile/src/field/camera/use-field-gestures.ts @@ -23,6 +23,7 @@ import { import type { FieldCamera, FieldCameraBounds, + FieldCameraPerspective, FieldPanBaseline, FieldViewport, FieldViewportSize, @@ -33,6 +34,7 @@ interface UseFieldGesturesOptions { readonly canvasSize: SharedValue; readonly cameraBounds: FieldCameraBounds; readonly gridBounds: FieldCameraBounds; + readonly perspective?: FieldCameraPerspective; readonly onViewportChange?: (viewport: FieldViewport) => void; readonly testID?: string; } @@ -47,6 +49,7 @@ export function useFieldGestures({ canvasSize, cameraBounds, gridBounds, + perspective = "director", onViewportChange, testID = "field", }: UseFieldGesturesOptions) { @@ -127,6 +130,7 @@ export function useFieldGestures({ panBaseline.value, event.translationX, event.translationY, + perspective, ); const halfWidth = (canvasSize.value.width * camera.metersPerPixel.value) / 2; @@ -181,6 +185,7 @@ export function useFieldGestures({ metersPerPixel: camera.metersPerPixel.value, }, canvasSize.value, + perspective, ), ); setSharedValue(pinchInitialized, true); @@ -198,6 +203,7 @@ export function useFieldGestures({ { x: event.focalX, y: event.focalY }, canvasSize.value, nextScale, + perspective, ); setFieldCamera(camera, { centerXMeters: clampFieldCameraAxis( diff --git a/packages/mobile/src/field/guidance.ts b/packages/mobile/src/field/guidance.ts index ae469020..e4efe625 100644 --- a/packages/mobile/src/field/guidance.ts +++ b/packages/mobile/src/field/guidance.ts @@ -23,8 +23,9 @@ function formatGuidanceAxis( if (Math.abs(steps) < 1e-9) return "0 steps"; const direction = steps < 0 ? negativeDirection : positiveDirection; const magnitude = formatMarchingSteps(Math.abs(steps)); - const word = Number(magnitude) === 1 ? "step" : "steps"; - return `${magnitude} ${word} toward ${direction}`; + return Number(magnitude) === 1 + ? `one step toward ${direction}` + : `${magnitude} steps toward ${direction}`; } /** diff --git a/packages/mobile/src/field/marching.ts b/packages/mobile/src/field/marching.ts index f8b6facf..06fd8be7 100644 --- a/packages/mobile/src/field/marching.ts +++ b/packages/mobile/src/field/marching.ts @@ -82,8 +82,8 @@ export function formatMarchingSteps(steps: number): string { function stepWord(steps: number, uppercase = true): string { const value = formatMarchingSteps(steps); - const noun = Math.abs(Number(value)) === 1 ? "Step" : "Steps"; - return uppercase ? `${value} ${noun}` : `${value} ${noun.toLowerCase()}`; + if (Math.abs(Number(value)) === 1) return uppercase ? "One Step" : "one step"; + return uppercase ? `${value} Steps` : `${value} steps`; } function yardLineText(yardLine: number): string { diff --git a/packages/mobile/src/field/render/field-canvas.tsx b/packages/mobile/src/field/render/field-canvas.tsx index 6a609fe0..5b70d109 100644 --- a/packages/mobile/src/field/render/field-canvas.tsx +++ b/packages/mobile/src/field/render/field-canvas.tsx @@ -25,6 +25,7 @@ import { } from "../camera/field-camera-policy"; import type { FieldCamera, + FieldCameraPerspective, FieldViewport, FieldViewportSize, } from "../camera/field-camera-types"; @@ -48,6 +49,7 @@ export interface FieldCanvasProps { readonly defaultViewport?: FieldViewport; readonly onViewportChange?: (viewport: FieldViewport) => void; readonly palette?: FieldRenderPalette; + readonly perspective?: FieldCameraPerspective; readonly livePosition?: SharedValue; readonly targetPosition?: FieldPoint; readonly drillScene?: DrillRenderScene; @@ -69,6 +71,7 @@ export function FieldCanvas({ defaultViewport, onViewportChange, palette = DEFAULT_FIELD_RENDER_PALETTE, + perspective = "director", livePosition: externalLivePosition, targetPosition, drillScene, @@ -120,6 +123,7 @@ export function FieldCanvas({ canvasSize, cameraBounds, gridBounds, + perspective, onViewportChange, testID, }); @@ -167,6 +171,7 @@ export function FieldCanvas({ template={template} paths={paths} palette={palette} + perspective={perspective} livePosition={livePosition} targetPosition={targetPosition} drillScene={drillScene} diff --git a/packages/mobile/src/field/render/field-drill-layer.tsx b/packages/mobile/src/field/render/field-drill-layer.tsx index d48b1b79..cc6d207e 100644 --- a/packages/mobile/src/field/render/field-drill-layer.tsx +++ b/packages/mobile/src/field/render/field-drill-layer.tsx @@ -39,12 +39,16 @@ const EMPTY_DOTS = Object.freeze([]) as readonly { readonly setId: number; readonly point: PhysicalFieldPoint; }[]; +const EMPTY_TRANSITIONS = Object.freeze( + [], +) as readonly PhysicalImmediateTransition[]; const LABEL_FONT_SIZE_PX = 12; const LABEL_LINE_HEIGHT_PX = 14; const MARKER_STROKE_PX = 2; const CONNECTOR_STROKE_PX = 1.25; const DASH_LENGTH_PX = 6; const DASH_GAP_PX = 4; +const EXTRA_TRANSITION_OPACITY = 0.68; export interface FieldDrillLayerProps { readonly scene?: DrillRenderScene; @@ -67,6 +71,8 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ }: FieldDrillLayerProps) { const labelFont = useFont(Montserrat_400Regular, LABEL_FONT_SIZE_PX); const entities = scene?.entities ?? EMPTY_ENTITIES; + const previousConnectors = scene?.previousConnectors ?? EMPTY_TRANSITIONS; + const nextConnectors = scene?.nextConnectors ?? EMPTY_TRANSITIONS; const previousDots = scene?.previousDots ?? EMPTY_DOTS; const nextDots = scene?.nextDots ?? EMPTY_DOTS; const targetPoint = resolveCurrentTargetPosition({ @@ -86,6 +92,22 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ palette={palette} /> ))} + {previousConnectors.map((transition) => ( + + ))} + {nextConnectors.map((transition) => ( + + ))} {previousDots.map((dot) => ( + ); +} + +function ExtraTransitionConnector({ + transition, + kind, + metersPerPixel, +}: { + readonly transition: PhysicalImmediateTransition; + readonly kind: "previous" | "next"; + readonly metersPerPixel: SharedValue; +}) { + const connectorPath = React.useMemo( + () => createPhysicalPath(transition.geometry), + [transition.geometry], + ); + const connectorStrokeWidth = useDerivedValue( + () => metersPerPixel.value * CONNECTOR_STROKE_PX, + ); + return ( + ); } diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx index 9e9686e7..8b9755e0 100644 --- a/packages/mobile/src/field/render/field-scene.tsx +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -6,6 +6,7 @@ import type { StandardFootballFieldTemplate } from "../template"; import type { FieldPoint } from "../types"; import type { FieldCamera, + FieldCameraPerspective, FieldViewportSize, } from "../camera/field-camera-types"; import type { FieldPaths } from "./create-field-paths"; @@ -27,6 +28,7 @@ interface FieldSceneProps { readonly template: StandardFootballFieldTemplate; readonly paths: FieldPaths; readonly palette: FieldRenderPalette; + readonly perspective: FieldCameraPerspective; readonly livePosition: SharedValue; readonly targetPosition?: FieldPoint; readonly drillScene?: DrillRenderScene; @@ -43,6 +45,7 @@ export function FieldScene({ template, paths, palette, + perspective, livePosition, targetPosition, drillScene, @@ -52,14 +55,18 @@ export function FieldScene({ showPerimeterStepGrid, showAuxiliaryFieldMarks, }: FieldSceneProps) { - const cameraTransform = useDerivedValue(() => [ - { translateX: canvasSize.value.width / 2 }, - { translateY: canvasSize.value.height / 2 }, - { scaleX: 1 / camera.metersPerPixel.value }, - { scaleY: -1 / camera.metersPerPixel.value }, - { translateX: -camera.centerXMeters.value }, - { translateY: -camera.centerYMeters.value }, - ]); + const cameraTransform = useDerivedValue(() => { + const scale = 1 / camera.metersPerPixel.value; + const performerView = perspective === "performer"; + return [ + { translateX: canvasSize.value.width / 2 }, + { translateY: canvasSize.value.height / 2 }, + { scaleX: performerView ? -scale : scale }, + { scaleY: performerView ? scale : -scale }, + { translateX: -camera.centerXMeters.value }, + { translateY: -camera.centerYMeters.value }, + ]; + }); return ( diff --git a/packages/mobile/src/field/render/page-dial-canvas.tsx b/packages/mobile/src/field/render/page-dial-canvas.tsx index 296a62d5..47ccdabd 100644 --- a/packages/mobile/src/field/render/page-dial-canvas.tsx +++ b/packages/mobile/src/field/render/page-dial-canvas.tsx @@ -22,6 +22,7 @@ export interface FieldPageDialCanvasProps { readonly innerColor?: string; readonly backgroundColor?: string; readonly knobColor?: string; + readonly showKnob?: boolean; readonly dividerColor?: string; readonly dividerSegments?: readonly FieldPageDialLineSegment[]; readonly testID?: string; @@ -83,6 +84,7 @@ export function FieldPageDialCanvas({ innerColor = "#222222", backgroundColor = "transparent", knobColor = "#FFFFFF", + showKnob = true, dividerColor = "rgba(255,255,255,0.28)", dividerSegments, testID = "page-dial-canvas", @@ -212,14 +214,16 @@ export function FieldPageDialCanvas({ /> {/* A larger, overscanned knob keeps its soft offset shadow inside the canvas. */} - - - + {showKnob ? ( + + + + ) : null}
); } diff --git a/packages/mobile/src/settings/SqliteSettingsRepository.ts b/packages/mobile/src/settings/SqliteSettingsRepository.ts index 35db1be5..133436c3 100644 --- a/packages/mobile/src/settings/SqliteSettingsRepository.ts +++ b/packages/mobile/src/settings/SqliteSettingsRepository.ts @@ -48,6 +48,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { field_perspective = ?, default_field_preset = ?, transition_metric_mode = ?, + count_display_mode = ?, guidance_enabled = ?, developer_mode_enabled = ?, show_cached_anchor_geometry = ?, @@ -77,6 +78,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { DEFAULT_APP_SETTINGS.fieldPerspective, DEFAULT_APP_SETTINGS.defaultFieldPreset, DEFAULT_APP_SETTINGS.transitionMetricMode, + DEFAULT_APP_SETTINGS.countDisplayMode, boolToSql(DEFAULT_APP_SETTINGS.guidanceEnabled), boolToSql(DEFAULT_APP_SETTINGS.developerModeEnabled), boolToSql(DEFAULT_APP_SETTINGS.showCachedAnchorGeometry), @@ -113,6 +115,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { field_perspective, default_field_preset, transition_metric_mode, + count_display_mode, guidance_enabled, developer_mode_enabled, show_cached_anchor_geometry, @@ -153,6 +156,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { field_perspective, default_field_preset, transition_metric_mode, + count_display_mode, guidance_enabled, developer_mode_enabled, show_cached_anchor_geometry, @@ -176,7 +180,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton_id) DO UPDATE SET appearance_mode = excluded.appearance_mode, drill_features_enabled = excluded.drill_features_enabled, @@ -184,6 +188,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { field_perspective = excluded.field_perspective, default_field_preset = excluded.default_field_preset, transition_metric_mode = excluded.transition_metric_mode, + count_display_mode = excluded.count_display_mode, guidance_enabled = excluded.guidance_enabled, developer_mode_enabled = excluded.developer_mode_enabled, show_cached_anchor_geometry = excluded.show_cached_anchor_geometry, @@ -215,6 +220,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { normalized.fieldPerspective, normalized.defaultFieldPreset, normalized.transitionMetricMode, + normalized.countDisplayMode, boolToSql(normalized.guidanceEnabled), boolToSql(normalized.developerModeEnabled), boolToSql(normalized.showCachedAnchorGeometry), @@ -256,6 +262,7 @@ function fromRow(row: AppSettingsRow): AppSettings { fieldPerspective: row.field_perspective, defaultFieldPreset: row.default_field_preset, transitionMetricMode: row.transition_metric_mode, + countDisplayMode: row.count_display_mode, guidanceEnabled: sqliteBoolean(row.guidance_enabled), developerModeEnabled: sqliteBoolean(row.developer_mode_enabled), showCachedAnchorGeometry: sqliteBoolean(row.show_cached_anchor_geometry), @@ -292,6 +299,7 @@ function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { row.field_perspective === settings.fieldPerspective && row.default_field_preset === settings.defaultFieldPreset && row.transition_metric_mode === settings.transitionMetricMode && + row.count_display_mode === settings.countDisplayMode && row.guidance_enabled === boolToSql(settings.guidanceEnabled) && row.developer_mode_enabled === boolToSql(settings.developerModeEnabled) && row.show_cached_anchor_geometry === diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts index 0269e855..54626a65 100644 --- a/packages/mobile/src/settings/__tests__/repository.test.ts +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -21,6 +21,7 @@ describe("app settings", () => { field_perspective: "performer", default_field_preset: "football-nfhs", transition_metric_mode: "step-size", + count_display_mode: "counts", guidance_enabled: 1, developer_mode_enabled: 0, show_cached_anchor_geometry: 0, @@ -55,6 +56,7 @@ describe("app settings", () => { field_perspective: "unknown", default_field_preset: "unknown", transition_metric_mode: "unknown", + count_display_mode: "unknown", guidance_enabled: "yes", developer_mode_enabled: 0, show_cached_anchor_geometry: 1, @@ -173,6 +175,7 @@ describe("app settings", () => { fieldPerspective: "director", defaultFieldPreset: "football-ncaa", transitionMetricMode: "crossing-counts", + countDisplayMode: "measures", guidanceEnabled: false, developerModeEnabled: true, showCachedAnchorGeometry: true, @@ -186,7 +189,7 @@ describe("app settings", () => { showTransitionMarkers: false, showAllTransitionSets: true, previousTransitionSetCount: 0, - nextTransitionSetCount: 50, + nextTransitionSetCount: 5, distanceGreenThresholdSteps: 0.75, distanceYellowThresholdSteps: 1.5, motionInterpolationEnabled: false, @@ -200,8 +203,9 @@ describe("app settings", () => { expect(updated).toMatchObject({ appearanceMode: "dark", drillTerminology: "pages", + countDisplayMode: "measures", previousTransitionSetCount: 0, - nextTransitionSetCount: 50, + nextTransitionSetCount: 5, distanceGreenThresholdSteps: 0.75, distanceYellowThresholdSteps: 1.5, mockLivePositionEnabled: true, @@ -234,7 +238,7 @@ describe("app settings", () => { show_transition_markers: 0, show_all_transition_sets: 1, previous_transition_set_count: 5, - next_transition_set_count: 6, + next_transition_set_count: 5, distance_green_threshold_steps: 0.75, distance_yellow_threshold_steps: 1.5, motion_interpolation_enabled: 0, @@ -278,11 +282,11 @@ describe("app settings", () => { expect( normalizeAppSettings({ previousTransitionSetCount: -1, - nextTransitionSetCount: 51, + nextTransitionSetCount: 6, }), ).toMatchObject({ previousTransitionSetCount: 0, - nextTransitionSetCount: 50, + nextTransitionSetCount: 5, }); expect( normalizeAppSettings({ @@ -387,36 +391,7 @@ class SettingsFakeDatabase { field_perspective: params[3], default_field_preset: params[4], transition_metric_mode: params[5], - guidance_enabled: params[6], - developer_mode_enabled: params[7], - show_cached_anchor_geometry: params[8], - show_comfortable_anchor_range: params[9], - show_perimeter_step_grid: params[10], - show_auxiliary_field_marks: params[11], - show_performer_labels: params[12], - show_performer_names: params[13], - show_prop_labels: params[14], - show_prop_names: params[15], - show_transition_markers: params[16], - show_all_transition_sets: params[17], - previous_transition_set_count: params[18], - next_transition_set_count: params[19], - distance_green_threshold_steps: params[20], - distance_yellow_threshold_steps: params[21], - motion_interpolation_enabled: params[22], - mock_live_position_enabled: params[23], - mock_live_position_x_steps: params[24], - mock_live_position_y_steps: params[25], - comfortable_anchor_range_meters: params[26], - }; - } else { - this.row = { - appearance_mode: params[1], - drill_features_enabled: params[2], - drill_terminology: params[3], - field_perspective: params[4], - default_field_preset: params[5], - transition_metric_mode: params[6], + count_display_mode: params[6], guidance_enabled: params[7], developer_mode_enabled: params[8], show_cached_anchor_geometry: params[9], @@ -438,8 +413,39 @@ class SettingsFakeDatabase { mock_live_position_x_steps: params[25], mock_live_position_y_steps: params[26], comfortable_anchor_range_meters: params[27], - active_drill_id: params[28], - selected_drill_page_id: params[29], + }; + } else { + this.row = { + appearance_mode: params[1], + drill_features_enabled: params[2], + drill_terminology: params[3], + field_perspective: params[4], + default_field_preset: params[5], + transition_metric_mode: params[6], + count_display_mode: params[7], + guidance_enabled: params[8], + developer_mode_enabled: params[9], + show_cached_anchor_geometry: params[10], + show_comfortable_anchor_range: params[11], + show_perimeter_step_grid: params[12], + show_auxiliary_field_marks: params[13], + show_performer_labels: params[14], + show_performer_names: params[15], + show_prop_labels: params[16], + show_prop_names: params[17], + show_transition_markers: params[18], + show_all_transition_sets: params[19], + previous_transition_set_count: params[20], + next_transition_set_count: params[21], + distance_green_threshold_steps: params[22], + distance_yellow_threshold_steps: params[23], + motion_interpolation_enabled: params[24], + mock_live_position_enabled: params[25], + mock_live_position_x_steps: params[26], + mock_live_position_y_steps: params[27], + comfortable_anchor_range_meters: params[28], + active_drill_id: params[29], + selected_drill_page_id: params[30], }; } return { lastInsertRowId: 1, changes: 1 }; @@ -456,6 +462,7 @@ function settingsRow(overrides: Record = {}) { field_perspective: "performer", default_field_preset: "football-nfhs", transition_metric_mode: "step-size", + count_display_mode: "counts", guidance_enabled: 1, developer_mode_enabled: 0, show_cached_anchor_geometry: 0, diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts index 0ffaafb4..da3c6b0a 100644 --- a/packages/mobile/src/settings/types.ts +++ b/packages/mobile/src/settings/types.ts @@ -4,11 +4,12 @@ import type { DrillTerminology } from "../drill/terminology"; export type FieldPerspective = "director" | "performer"; export type AppearanceMode = "system" | "light" | "dark"; export type TransitionMetricMode = "step-size" | "crossing-counts"; +export type CountDisplayMode = "counts" | "measures"; export const DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS = 20; export const MAX_COMFORTABLE_ANCHOR_RANGE_METERS = 200; export const MIN_TRANSITION_SET_COUNT = 0; -export const MAX_TRANSITION_SET_COUNT = 50; +export const MAX_TRANSITION_SET_COUNT = 5; export const DEFAULT_DISTANCE_GREEN_THRESHOLD_STEPS = 0.5; export const DEFAULT_DISTANCE_YELLOW_THRESHOLD_STEPS = 1; @@ -20,6 +21,7 @@ export interface AppSettings { readonly fieldPerspective: FieldPerspective; readonly defaultFieldPreset: FieldPresetId; readonly transitionMetricMode: TransitionMetricMode; + readonly countDisplayMode: CountDisplayMode; readonly guidanceEnabled: boolean; readonly developerModeEnabled: boolean; readonly showCachedAnchorGeometry: boolean; @@ -54,6 +56,7 @@ export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ fieldPerspective: "performer", defaultFieldPreset: "football-nfhs", transitionMetricMode: "step-size", + countDisplayMode: "counts", guidanceEnabled: true, developerModeEnabled: false, showCachedAnchorGeometry: false, @@ -87,6 +90,7 @@ export const APP_PREFERENCE_KEYS = Object.freeze([ "fieldPerspective", "defaultFieldPreset", "transitionMetricMode", + "countDisplayMode", "guidanceEnabled", "developerModeEnabled", "showCachedAnchorGeometry", @@ -156,6 +160,11 @@ export function normalizeAppSettings(value?: unknown): AppSettings { candidate.transitionMetricMode === "crossing-counts" ? candidate.transitionMetricMode : DEFAULT_APP_SETTINGS.transitionMetricMode, + countDisplayMode: + candidate.countDisplayMode === "counts" || + candidate.countDisplayMode === "measures" + ? candidate.countDisplayMode + : DEFAULT_APP_SETTINGS.countDisplayMode, guidanceEnabled: booleanOrDefault( candidate.guidanceEnabled, DEFAULT_APP_SETTINGS.guidanceEnabled, diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index e977c3cd..59393e54 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -15,7 +15,7 @@ describe("mobile app SQLite schema preparation", () => { const sql = executed.join("\n"); expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); - expect(MOBILE_SCHEMA_VERSION).toBe(7); + expect(MOBILE_SCHEMA_VERSION).toBe(8); expect(sql).toContain("PRAGMA journal_mode = WAL"); expect(sql).toContain("PRAGMA foreign_keys = OFF"); expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); @@ -42,12 +42,15 @@ describe("mobile app SQLite schema preparation", () => { ); expect(sql).toContain("default_field_preset TEXT NOT NULL"); expect(sql).toContain("show_perimeter_step_grid INTEGER NOT NULL"); + expect(sql).toContain("count_display_mode TEXT NOT NULL DEFAULT 'counts'"); expect(sql).toContain( "previous_transition_set_count INTEGER NOT NULL DEFAULT 1", ); expect(sql).toContain( "next_transition_set_count INTEGER NOT NULL DEFAULT 1", ); + expect(sql).toContain("previous_transition_set_count <= 5"); + expect(sql).toContain("next_transition_set_count <= 5"); expect(sql).toContain( "distance_green_threshold_steps REAL NOT NULL DEFAULT 0.5", ); diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index 5abc0dd3..55ff6d0e 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -10,7 +10,7 @@ export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; * stable, a version mismatch intentionally rebuilds this disposable database * rather than carrying migration code for development-only layouts. */ -export const MOBILE_SCHEMA_VERSION = 7; +export const MOBILE_SCHEMA_VERSION = 8; export const DRILLS_TABLE = "drills"; export const DRILL_SETS_TABLE = "drill_sets"; @@ -158,6 +158,8 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { CHECK (default_field_preset IN (${FIELD_PRESET_SQL_LIST})), transition_metric_mode TEXT NOT NULL DEFAULT 'step-size' CHECK (transition_metric_mode IN ('step-size', 'crossing-counts')), + count_display_mode TEXT NOT NULL DEFAULT 'counts' + CHECK (count_display_mode IN ('counts', 'measures')), guidance_enabled INTEGER NOT NULL DEFAULT 1 CHECK (guidance_enabled IN (0, 1)), developer_mode_enabled INTEGER NOT NULL DEFAULT 0 @@ -185,13 +187,13 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { previous_transition_set_count INTEGER NOT NULL DEFAULT 1 CHECK ( previous_transition_set_count >= 0 AND - previous_transition_set_count <= 50 AND + previous_transition_set_count <= 5 AND previous_transition_set_count = CAST(previous_transition_set_count AS INTEGER) ), next_transition_set_count INTEGER NOT NULL DEFAULT 1 CHECK ( next_transition_set_count >= 0 AND - next_transition_set_count <= 50 AND + next_transition_set_count <= 5 AND next_transition_set_count = CAST(next_transition_set_count AS INTEGER) ), distance_green_threshold_steps REAL NOT NULL DEFAULT 0.5 From 2e4c469fb3239a10a70e9020e87d72ec66751f2a Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 03:03:18 -0500 Subject: [PATCH 087/101] fix(field): repair page dial seam and startup crash --- .../__tests__/page-dial-math.test.ts | 36 +++++---- .../field/page-dial/page-dial-gesture.ts | 9 ++- .../field/page-dial/page-dial-math.ts | 81 +++++++++++++------ .../features/field/page-dial/page-dial.tsx | 15 +--- .../src/field/render/page-dial-canvas.tsx | 51 ++++++++---- 5 files changed, 122 insertions(+), 70 deletions(-) diff --git a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts index 1b1ee2f9..88d3309f 100644 --- a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts +++ b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts @@ -8,6 +8,7 @@ import { pageDialPointIsInRingHitRegion, pageDialProgressForAngle, pageDialProgressForPoint, + pageDialProgressForPointNearReference, PAGE_DIAL_START_ANGLE_DEGREES, PAGE_DIAL_USABLE_ARC_DEGREES, normalizePageIndex, @@ -23,7 +24,7 @@ import { const radians = (degrees: number) => (degrees * Math.PI) / 180; describe("page dial math", () => { - test("maps first and last pages to distinct arc endpoints", () => { + test("maps first and last pages to the shared top endpoint", () => { expect(normalizePageIndex(0, 38)).toBe(0); expect(normalizePageIndex(37, 38)).toBe(1); expect(pageDialAngleForIndex(0, 38)).toBeCloseTo( @@ -32,28 +33,33 @@ describe("page dial math", () => { expect(pageDialAngleForIndex(37, 38)).toBeCloseTo( radians(PAGE_DIAL_START_ANGLE_DEGREES + PAGE_DIAL_USABLE_ARC_DEGREES), ); + const first = pageDialPointForProgress(0, 200); + const last = pageDialPointForProgress(1, 200); + expect(first.x).toBeCloseTo(last.x); + expect(first.y).toBeCloseTo(last.y); }); - test("clamps either side of the top seam to the nearest endpoint", () => { + test("uses drag history to disambiguate the shared top endpoint", () => { + const diameter = 200; + const center = diameter / 2; + const topY = center - 90; + expect( + pageDialProgressForPointNearReference(center, topY, diameter, 0.02), + ).toBe(0); + expect( + pageDialProgressForPointNearReference(center, topY, diameter, 0.98), + ).toBe(1); expect(pageDialIndexForAngle(radians(-89), 38)).toBe(0); expect(pageDialIndexForAngle(radians(-91), 38)).toBe(37); expect(pageDialIndexForAngle(radians(90), 5)).toBe(2); }); - test("keeps cardinal angles on one continuous valid arc", () => { - expect(pageDialProgressForAngle(radians(0))).toBeCloseTo(85 / 350); + test("keeps cardinal angles on one continuous full circle", () => { + expect(pageDialProgressForAngle(radians(0))).toBeCloseTo(0.25); expect(pageDialProgressForAngle(radians(90))).toBeCloseTo(0.5); - expect(pageDialProgressForAngle(radians(180))).toBeCloseTo(265 / 350); - expect(pageDialAngleIsInValidArc(radians(-85))).toBe(true); - expect(pageDialAngleIsInValidArc(radians(265))).toBe(true); - }); - - test("uses a deterministic top dead zone without wrapping", () => { - expect(pageDialProgressForAngle(radians(-90))).toBe(0); - expect(pageDialProgressForAngle(radians(-89.9))).toBe(0); - expect(pageDialProgressForAngle(radians(-90.1))).toBe(1); - expect(pageDialProgressForAngle(radians(-85.1))).toBe(0); - expect(pageDialProgressForAngle(radians(265.1))).toBe(1); + expect(pageDialProgressForAngle(radians(180))).toBeCloseTo(0.75); + expect(pageDialAngleIsInValidArc(radians(-90))).toBe(true); + expect(pageDialAngleIsInValidArc(radians(270))).toBe(true); expect(pageDialIndexForProgress(0.5, 5)).toBe(2); }); diff --git a/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts index ce686528..474c5815 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts +++ b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts @@ -14,7 +14,7 @@ import { normalizePageIndex, pageDialIndexForProgress, pageDialPointIsInControlHitTarget, - pageDialProgressForPoint, + pageDialProgressForPointNearReference, pageDialPointIsInRingHitRegion, } from "./page-dial-math"; @@ -48,7 +48,12 @@ export function usePageDialGesture({ const updateFromPoint = (x: number, y: number, shouldHaptic = true) => { "worklet"; if (!ringActive.value || pageCount <= 0) return; - const nextProgress = pageDialProgressForPoint(x, y, diameter); + const nextProgress = pageDialProgressForPointNearReference( + x, + y, + diameter, + provisionalProgress.value, + ); const nextIndex = pageDialIndexForProgress(nextProgress, pageCount); setSharedValue(provisionalProgress, nextProgress); if (nextIndex === previewIndex.value) return; diff --git a/apps/mobile/src/features/field/page-dial/page-dial-math.ts b/apps/mobile/src/features/field/page-dial/page-dial-math.ts index 3f857ff0..9ce247de 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-math.ts +++ b/apps/mobile/src/features/field/page-dial/page-dial-math.ts @@ -4,12 +4,10 @@ * right and positive values rotate clockwise because y grows downwards. */ -export const PAGE_DIAL_DEAD_ZONE_DEGREES = 10; -export const PAGE_DIAL_USABLE_ARC_DEGREES = 360 - PAGE_DIAL_DEAD_ZONE_DEGREES; -export const PAGE_DIAL_START_ANGLE_DEGREES = - -90 + PAGE_DIAL_DEAD_ZONE_DEGREES / 2; -export const PAGE_DIAL_END_ANGLE_DEGREES = - PAGE_DIAL_START_ANGLE_DEGREES + PAGE_DIAL_USABLE_ARC_DEGREES; +export const PAGE_DIAL_DEAD_ZONE_DEGREES = 0; +export const PAGE_DIAL_USABLE_ARC_DEGREES = 360; +export const PAGE_DIAL_START_ANGLE_DEGREES = -90; +export const PAGE_DIAL_END_ANGLE_DEGREES = 270; export const PAGE_DIAL_RING_THICKNESS_RATIO = 0.075; export const PAGE_DIAL_INNER_DISK_DIAMETER_RATIO = 0.86; @@ -104,10 +102,7 @@ export function pageDialRelativeAngle(angleRadians: number): number { export function pageDialAngleIsInValidArc(angleRadians: number): boolean { "worklet"; - return ( - pageDialRelativeAngle(angleRadians) <= - PAGE_DIAL_USABLE_ARC_DEGREES * DEGREES_TO_RADIANS - ); + return Number.isFinite(angleRadians); } export const isPageDialAngleInValidArc = pageDialAngleIsInValidArc; @@ -115,18 +110,41 @@ export const isPageDialAngleInValidArc = pageDialAngleIsInValidArc; export function pageDialProgressForAngle(angleRadians: number): number { "worklet"; if (!Number.isFinite(angleRadians)) return 0; + return pageDialRelativeAngle(angleRadians) / FULL_TURN_RADIANS; +} - const usableArcRadians = PAGE_DIAL_USABLE_ARC_DEGREES * DEGREES_TO_RADIANS; - const relative = pageDialRelativeAngle(angleRadians); - if (relative <= usableArcRadians) return relative / usableArcRadians; - - // The only invalid portion is the top dead zone. Its midpoint is a stable - // tie-breaker: the exact top angle belongs to the first page, the clockwise - // half belongs to the first page, and the counter-clockwise half belongs to - // the last page. This prevents an accidental first/last wrap. - const distanceFromEnd = relative - usableArcRadians; - const distanceFromStart = FULL_TURN_RADIANS - relative; - return distanceFromStart <= distanceFromEnd ? 0 : 1; +/** + * Resolve the top seam against the user's current drag position. The top point + * is intentionally shared by progress 0 (first set) and progress 1 (last set). + * Approaching it clockwise from the end of the ring resolves to 1; approaching + * counter-clockwise from the start resolves to 0. Continuing beyond either + * endpoint clamps there rather than wrapping unexpectedly to the other end. + */ +export function pageDialProgressForAngleNearReference( + angleRadians: number, + referenceProgress: number, +): number { + "worklet"; + const wrapped = pageDialProgressForAngle(angleRadians); + const reference = clamp( + Number.isFinite(referenceProgress) ? referenceProgress : 0, + 0, + 1, + ); + + let candidate = wrapped; + if (wrapped <= 0.5) { + const afterEnd = wrapped + 1; + if (Math.abs(afterEnd - reference) < Math.abs(candidate - reference)) { + candidate = afterEnd; + } + } else { + const beforeStart = wrapped - 1; + if (Math.abs(beforeStart - reference) < Math.abs(candidate - reference)) { + candidate = beforeStart; + } + } + return clamp(candidate, 0, 1); } export function pageDialProgressForPoint( @@ -140,6 +158,21 @@ export function pageDialProgressForPoint( return pageDialProgressForAngle(Math.atan2(y - center, x - center)); } +export function pageDialProgressForPointNearReference( + x: number, + y: number, + diameter: number, + referenceProgress: number, +): number { + "worklet"; + const center = diameter / 2; + if (x === center && y === center) return clamp(referenceProgress, 0, 1); + return pageDialProgressForAngleNearReference( + Math.atan2(y - center, x - center), + referenceProgress, + ); +} + export function pageDialIndexForProgress( progress: number, pageCount: number, @@ -190,13 +223,15 @@ export function pageDialPointForAngle( export function pageDialPointForProgress( progress: number, diameter: number, - radius = getPageDialRingRadius(diameter), + radius?: number, ): PageDialPoint { "worklet"; + const resolvedRadius = + radius ?? diameter / 2 - (diameter * PAGE_DIAL_RING_THICKNESS_RATIO) / 2; return pageDialPointForAngle( pageDialAngleForProgress(progress), diameter, - radius, + resolvedRadius, ); } diff --git a/apps/mobile/src/features/field/page-dial/page-dial.tsx b/apps/mobile/src/features/field/page-dial/page-dial.tsx index 909c8aad..c3836b85 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial.tsx +++ b/apps/mobile/src/features/field/page-dial/page-dial.tsx @@ -94,8 +94,7 @@ export function PageDial({ [onSelectIndex, pageCount, provisionalProgress, selectedIndex], ); - const resolvedInnerColor = - innerColor ?? colorWithOpacity(theme.surfaceRaised, 0.38); + const resolvedInnerColor = innerColor ?? "transparent"; const resolvedBackgroundColor = backgroundColor ?? "transparent"; const resolvedForegroundColor = foregroundColor ?? theme.text; @@ -117,6 +116,7 @@ export function PageDial({ > + - ); } - -function colorWithOpacity(color: string, opacity: number): string { - const match = /^#([0-9a-f]{6})$/i.exec(color); - if (!match) return color; - const hex = match[1]; - const red = Number.parseInt(hex.slice(0, 2), 16); - const green = Number.parseInt(hex.slice(2, 4), 16); - const blue = Number.parseInt(hex.slice(4, 6), 16); - return `rgba(${red}, ${green}, ${blue}, ${opacity})`; -} diff --git a/packages/mobile/src/field/render/page-dial-canvas.tsx b/packages/mobile/src/field/render/page-dial-canvas.tsx index 47ccdabd..8b65f5ad 100644 --- a/packages/mobile/src/field/render/page-dial-canvas.tsx +++ b/packages/mobile/src/field/render/page-dial-canvas.tsx @@ -34,7 +34,6 @@ const RING_THICKNESS_RATIO = 0.075; const KNOB_DIAMETER_RATIO = 0.16; const CANVAS_OVERSCAN_RATIO = 0.09; const DIVIDER_STROKE_RATIO = 0.012; -const ACTIVE_OVERLAP_PROGRESS = 0.008; function pointAtRadius( diameter: number, @@ -98,6 +97,7 @@ export function FieldPageDialCanvas({ const innerDiskRadius = (diameter * INNER_DISK_DIAMETER_RATIO) / 2; const centerDiskRadius = (diameter * CENTER_DISK_DIAMETER_RATIO) / 2; const segments = dividerSegments ?? getDefaultDividerSegments(diameter); + const fullCircle = Math.abs(usableArcDegrees) >= 359.999; const trackPath = React.useMemo(() => { const inset = canvasOverscan + ringThickness / 2; @@ -136,13 +136,9 @@ export function FieldPageDialCanvas({ const value = progress.value; return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0; }); - const activeProgress = useDerivedValue(() => { - const value = normalizedProgress.value; - return Math.min( - 1, - Math.max(ACTIVE_OVERLAP_PROGRESS, value + ACTIVE_OVERLAP_PROGRESS), - ); - }); + const fullActiveOpacity = useDerivedValue(() => + fullCircle && normalizedProgress.value >= 0.999999 ? 1 : 0, + ); const knobX = useDerivedValue(() => { const angle = ((startAngleDegrees + normalizedProgress.value * usableArcDegrees) * @@ -177,18 +173,39 @@ export function FieldPageDialCanvas({ {/* Paint the complete track first, then overlap it with the active arc. */} - + {fullCircle ? ( + + ) : ( + + )} + {fullCircle ? ( + + ) : null} Date: Thu, 6 Aug 2026 03:11:11 -0500 Subject: [PATCH 088/101] feat(mobile): refine field HUD and drill controls --- .../src/components/spinning-loader-icon.tsx | 48 ++++ .../drill/components/drill-list-item.tsx | 81 +++++-- .../components/drill-properties-dialog.tsx | 166 ++----------- .../components/drill-selection-dialog.tsx | 228 ++++++++---------- .../components/performer-selection-dialog.tsx | 9 +- .../src/features/drill/drill-list-screen.tsx | 1 - .../drill/use-drill-list-controller.ts | 48 ---- .../__tests__/field-overlay-layout.test.ts | 48 ++-- .../features/field/coordinate-lines-view.tsx | 106 ++++++++ .../features/field/drill-pill/drill-pill.tsx | 9 +- .../field/drill-pill/drill-set-list.tsx | 11 +- .../drill-pill/drill-set-metric-grid.tsx | 36 +-- .../src/features/field/field-hud-state.ts | 22 +- .../features/field/field-overlay-layout.tsx | 18 +- .../src/features/field/field-screen.tsx | 22 +- .../features/field/live-position-hud-state.ts | 10 +- .../src/features/field/live-position-hud.tsx | 40 +-- .../features/settings/anchor-editor-form.ts | 8 +- .../settings/anchor-editor-screen.tsx | 5 +- .../features/settings/anchor-list-screen.tsx | 4 +- .../settings/developer-settings-screen.tsx | 12 +- .../settings/network-detail-screen.tsx | 4 +- .../settings/network-profile-form.tsx | 4 +- .../src/features/settings/settings-screen.tsx | 40 ++- .../settings/use-anchor-editor-controller.ts | 1 + .../__tests__/drill-shape-policy.test.ts | 8 +- .../field/__tests__/field-camera-math.test.ts | 18 +- .../src/field/__tests__/field-paths.test.ts | 7 +- .../src/field/camera/field-camera-math.ts | 10 +- packages/mobile/src/field/marching.ts | 53 ++-- .../src/field/render/create-field-paths.ts | 17 +- .../src/field/render/drill-shape-policy.ts | 9 +- .../src/field/render/field-drill-layer.tsx | 165 +++++++------ .../src/field/render/field-position-layer.tsx | 34 +-- .../src/field/render/field-render-tokens.ts | 9 +- .../mobile/src/field/render/field-scene.tsx | 2 + .../src/field/render/field-static-layer.tsx | 104 ++++++-- .../src/settings/SqliteSettingsRepository.ts | 10 +- .../src/settings/__tests__/repository.test.ts | 83 ++++--- packages/mobile/src/settings/types.ts | 22 ++ .../storage/__tests__/mobileDatabase.test.ts | 6 +- packages/mobile/src/storage/mobileDatabase.ts | 4 +- 42 files changed, 899 insertions(+), 643 deletions(-) create mode 100644 apps/mobile/src/components/spinning-loader-icon.tsx create mode 100644 apps/mobile/src/features/field/coordinate-lines-view.tsx diff --git a/apps/mobile/src/components/spinning-loader-icon.tsx b/apps/mobile/src/components/spinning-loader-icon.tsx new file mode 100644 index 00000000..f9b63124 --- /dev/null +++ b/apps/mobile/src/components/spinning-loader-icon.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Animated, Easing } from "react-native"; +import { LoaderCircle } from "lucide-react-native"; +import { Icon } from "@eight2five/ui/components/icon"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; + +export function SpinningLoaderIcon({ + color, + size = "md", +}: { + readonly color?: string; + readonly size?: React.ComponentProps["size"]; +}) { + const theme = useEight2FiveTheme(); + const resolvedColor = color ?? theme.raw.white; + const [spin] = React.useState(() => new Animated.Value(0)); + + React.useEffect(() => { + const animation = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + animation.start(); + return () => animation.stop(); + }, [spin]); + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/drill/components/drill-list-item.tsx b/apps/mobile/src/features/drill/components/drill-list-item.tsx index 4978654b..7b697450 100644 --- a/apps/mobile/src/features/drill/components/drill-list-item.tsx +++ b/apps/mobile/src/features/drill/components/drill-list-item.tsx @@ -1,8 +1,9 @@ import React from "react"; +import { Animated } from "react-native"; import { + CircleCheck, CirclePlus, CircleUserRound, - CircleX, Info, } from "lucide-react-native"; import type { Drill, DrillTerms } from "@eight2five/mobile/drill"; @@ -93,16 +94,14 @@ export const DrillListItem = React.memo(function DrillListItem({ icon={Info} disabled={busy} onPress={onOpenInfo} - backgroundColor={theme.raw.black} - iconColor={theme.raw.white} + iconColor={theme.text} /> - + @@ -140,14 +131,12 @@ function DrillActionButton({ icon, disabled, onPress, - backgroundColor, iconColor, }: { readonly label: string; readonly icon: React.ElementType; readonly disabled: boolean; readonly onPress: () => void; - readonly backgroundColor: string; readonly iconColor: string; }) { return ( @@ -160,13 +149,67 @@ function DrillActionButton({ style={{ width: 48, height: 48, - borderRadius: 24, alignItems: "center", justifyContent: "center", - backgroundColor, }} > - + ); } + +function AnimatedSelectionIcon({ + active, + color, +}: { + readonly active: boolean; + readonly color: string; +}) { + const [progress] = React.useState(() => new Animated.Value(active ? 1 : 0)); + React.useEffect(() => { + Animated.timing(progress, { + toValue: active ? 1 : 0, + duration: 180, + useNativeDriver: true, + }).start(); + }, [active, progress]); + + const plusOpacity = progress.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], + }); + const checkOpacity = progress; + const plusScale = progress.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0.9], + }); + const checkScale = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0.9, 1], + }); + + return ( + <> + + + + + + + + ); +} diff --git a/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx index 3445b23b..c107ebaa 100644 --- a/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx +++ b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx @@ -1,35 +1,27 @@ import React from "react"; import { Alert } from "react-native"; -import { Check, Trash2 } from "lucide-react-native"; +import { Trash2, X } from "lucide-react-native"; import type { Drill, DrillDocument, DrillTerms, - UpdateDrillPropertiesInput, } from "@eight2five/mobile/drill"; import { Button, ButtonIcon, - ButtonSpinner, ButtonText, } from "@eight2five/ui/components/button"; -import { - FormControl, - FormControlLabel, - FormControlLabelText, -} from "@eight2five/ui/components/form-control"; import { Heading } from "@eight2five/ui/components/heading"; import { Icon } from "@eight2five/ui/components/icon"; -import { Input, InputField } from "@eight2five/ui/components/input"; import { Modal, ModalBackdrop, ModalBody, + ModalCloseButton, ModalContent, ModalFooter, ModalHeader, } from "@eight2five/ui/components/modal"; -import { Pressable } from "@eight2five/ui/components/pressable"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { @@ -39,9 +31,9 @@ import { useEight2FiveTheme, } from "@eight2five/ui/theme"; +import { SpinningLoaderIcon } from "../../../components/spinning-loader-icon"; import { SettingsMessage } from "../../settings/settings-components"; -import { DRILL_ICON_NAMES, resolveDrillIcon } from "../drill-icons"; -import { DRILL_NAME_MAX_LENGTH, validateDrillName } from "../drill-management"; +import { resolveDrillIcon } from "../drill-icons"; export function DrillPropertiesDialog({ drill, @@ -52,7 +44,6 @@ export function DrillPropertiesDialog({ saving, error, onClose, - onSave, onDelete, }: { readonly drill?: Drill; @@ -63,32 +54,12 @@ export function DrillPropertiesDialog({ readonly saving: boolean; readonly error?: Error; readonly onClose: () => void; - readonly onSave: (input: UpdateDrillPropertiesInput) => Promise; readonly onDelete: () => Promise; }) { const theme = useEight2FiveTheme(); - const [name, setName] = React.useState(drill?.name ?? ""); - const [iconName, setIconName] = React.useState( - drill?.metadata?.lucideIcon, - ); - const [formError, setFormError] = React.useState(); - if (!drill) return null; const metadata = document?.metadata ?? drill.metadata; - - const save = async () => { - const validationError = validateDrillName(name); - setFormError(validationError); - if (validationError) return; - try { - await onSave({ - name: name.trim(), - lucideIcon: iconName ?? null, - }); - } catch (cause) { - setFormError(cause instanceof Error ? cause.message : String(cause)); - } - }; + const DrillIcon = resolveDrillIcon(drill.metadata?.lucideIcon); const confirmDelete = () => { Alert.alert( @@ -106,18 +77,17 @@ export function DrillPropertiesDialog({ }; return ( - { - if (!saving && !loading) onClose(); - }} - size="lg" - avoidKeyboard - > + - + Drill Info + + + @@ -127,61 +97,29 @@ export function DrillPropertiesDialog({ {error ? ( {error.message} ) : null} - {formError ? ( - {formError} - ) : null} - - - Drill name - - - { - setName(value); - if (formError) setFormError(undefined); - }} - maxLength={DRILL_NAME_MAX_LENGTH} - autoCapitalize="words" - accessibilityLabel="Drill name" - /> - - - - + + - Card icon + {drill.name} - - setIconName(undefined)} - /> - {DRILL_ICON_NAMES.map((nameOption) => ( - setIconName(nameOption)} - /> - ))} - @@ -206,19 +144,11 @@ export function DrillPropertiesDialog({ isDisabled={saving || loading} accessibilityLabel={`Delete ${drill.name}`} > - + {saving ? : } Delete - @@ -226,54 +156,6 @@ export function DrillPropertiesDialog({ ); } -function HStackWrap({ children }: { readonly children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function IconChoice({ - label, - selected, - icon, - onPress, -}: { - readonly label: string; - readonly selected: boolean; - readonly icon: React.ElementType; - readonly onPress: () => void; -}) { - const theme = useEight2FiveTheme(); - return ( - - - - ); -} - function MetadataRow({ label, value }: { label: string; value?: string }) { const theme = useEight2FiveTheme(); return ( diff --git a/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx b/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx index 09a9cef2..687334f5 100644 --- a/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx +++ b/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx @@ -1,10 +1,8 @@ import React from "react"; import { useWindowDimensions } from "react-native"; -import { CircleCheck, X } from "lucide-react-native"; -import type { Drill, DrillTerms } from "@eight2five/mobile/drill"; +import { X } from "lucide-react-native"; import { FlatList } from "@eight2five/ui/components/flat-list"; import { Heading } from "@eight2five/ui/components/heading"; -import { HStack } from "@eight2five/ui/components/hstack"; import { Icon } from "@eight2five/ui/components/icon"; import { Modal, @@ -13,149 +11,119 @@ import { ModalContent, ModalHeader, } from "@eight2five/ui/components/modal"; -import { Pressable } from "@eight2five/ui/components/pressable"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; -import { - eight2FiveFonts, - eight2FiveRadii, - eight2FiveSpacing, - useEight2FiveTheme, -} from "@eight2five/ui/theme"; - -import { resolveDrillIcon } from "../drill-icons"; -import { formatDrillCount } from "../drill-management"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; -export interface DrillSelectionEntry { - readonly drill: Drill; - readonly pageCount: number; -} +import { DrillListItem } from "./drill-list-item"; +import { DrillPropertiesDialog } from "./drill-properties-dialog"; +import { PerformerSelectionDialog } from "./performer-selection-dialog"; +import { useDrillListController } from "../use-drill-list-controller"; export function DrillSelectionDialog({ - entries, - terms, - activeDrillId, isOpen, - disabled, onClose, - onSelect, }: { - readonly entries: readonly DrillSelectionEntry[]; - readonly terms: DrillTerms; - readonly activeDrillId: string | null; readonly isOpen: boolean; - readonly disabled: boolean; readonly onClose: () => void; - readonly onSelect: (drillId: string) => void; }) { const { height } = useWindowDimensions(); - if (!isOpen) return null; - return ( - - - - - Select Drill - - - - - - - - ); -} - -/** Shared selection-only list body for field and future drill pickers. */ -export function DrillSelectionList({ - entries, - terms, - activeDrillId, - disabled, - maxHeight, - onSelect, -}: { - readonly entries: readonly DrillSelectionEntry[]; - readonly terms: DrillTerms; - readonly activeDrillId: string | null; - readonly disabled: boolean; - readonly maxHeight?: number; - readonly onSelect: (drillId: string) => void; -}) { const theme = useEight2FiveTheme(); + const controller = useDrillListController(); + const renderItem = React.useCallback( - ({ item }: { item: DrillSelectionEntry }) => { - const active = item.drill.id === activeDrillId; - const DrillIcon = resolveDrillIcon(item.drill.metadata?.lucideIcon); - return ( - onSelect(item.drill.id)} - style={{ - minHeight: 64, - justifyContent: "center", - borderRadius: eight2FiveRadii.md, - borderWidth: active ? 2 : 1, - borderColor: active ? theme.accent : theme.border, - backgroundColor: active ? theme.accentSoft : theme.surfaceRaised, - padding: eight2FiveSpacing.sm, - }} - testID={`drill-selection-${item.drill.id}`} - > - - - - - {item.drill.name} - - - {formatDrillCount(item.pageCount, terms)} - - - {active ? ( - - ) : null} - - - ); - }, - [activeDrillId, disabled, onSelect, terms, theme], + ({ item }: { item: (typeof controller.entries)[number] }) => ( + void controller.openProperties(item.drill)} + onSelectPerformer={() => + void controller.openPerformerSelection(item.drill) + } + onToggleActive={() => + void controller.toggleActive(item.drill).catch(() => undefined) + } + /> + ), + [controller], ); return ( - entry.drill.id} - renderItem={renderItem} - style={maxHeight === undefined ? undefined : { maxHeight }} - contentContainerStyle={{ gap: eight2FiveSpacing.sm }} - ListEmptyComponent={ - No drills uploaded. - } - testID="drill-selection-list" - /> + <> + + + + + Select Drill + + + + + entry.drill.id} + renderItem={renderItem} + style={{ maxHeight: Math.max(260, height * 0.66) }} + contentContainerStyle={{ + gap: eight2FiveSpacing.sm, + padding: eight2FiveSpacing.md, + }} + ListEmptyComponent={ + + + No drills uploaded. + + + } + testID="drill-selection-list" + /> + + + + + + { + const drill = controller.propertiesDialog?.drill; + if (!drill) return; + await controller.remove(drill); + }} + /> + ); } diff --git a/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx index 2603cc6f..076de3c4 100644 --- a/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx +++ b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx @@ -1,11 +1,7 @@ import React from "react"; import { useWindowDimensions } from "react-native"; import type { DrillDocument, DrillEntity } from "@eight2five/drill-schema"; -import { - Button, - ButtonSpinner, - ButtonText, -} from "@eight2five/ui/components/button"; +import { Button, ButtonText } from "@eight2five/ui/components/button"; import { Heading } from "@eight2five/ui/components/heading"; import { HStack } from "@eight2five/ui/components/hstack"; import { @@ -26,6 +22,7 @@ import { useEight2FiveTheme, } from "@eight2five/ui/theme"; +import { SpinningLoaderIcon } from "../../../components/spinning-loader-icon"; import { SettingsMessage } from "../../settings/settings-components"; import { getPerformerSymbolGroups } from "../drill-import"; @@ -236,7 +233,7 @@ function PerformerSelectionDialogContent({ }} isDisabled={!selectedPerformer || importing} > - {importing ? : null} + {importing ? : null} {importing ? "Saving…" : confirmLabel} diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx index d6c5d857..68fd5e82 100644 --- a/apps/mobile/src/features/drill/drill-list-screen.tsx +++ b/apps/mobile/src/features/drill/drill-list-screen.tsx @@ -155,7 +155,6 @@ export function DrillListScreen() { } error={controller.propertiesError} onClose={controller.closeProperties} - onSave={controller.updateProperties} onDelete={async () => { const drill = controller.propertiesDialog?.drill; if (!drill) return; diff --git a/apps/mobile/src/features/drill/use-drill-list-controller.ts b/apps/mobile/src/features/drill/use-drill-list-controller.ts index f0b3abc4..4b0b44af 100644 --- a/apps/mobile/src/features/drill/use-drill-list-controller.ts +++ b/apps/mobile/src/features/drill/use-drill-list-controller.ts @@ -7,7 +7,6 @@ import { type Drill, type DrillDocument, type DrillRepository, - type UpdateDrillPropertiesInput, } from "@eight2five/mobile/drill"; import { @@ -246,43 +245,6 @@ export function useDrillListController() { } }, [propertiesLoading]); - const updateProperties = React.useCallback( - async (input: UpdateDrillPropertiesInput) => { - const current = propertiesDialog; - if (!current) return; - try { - const saved = await mutate(current.drill.id, (repository) => - repository.updateDrillProperties(current.drill.id, input), - ); - setPropertiesError(undefined); - setPropertiesDialog((dialog) => - dialog?.drill.id === saved.id - ? { - ...dialog, - drill: saved, - document: dialog.document - ? { - ...dialog.document, - metadata: { - ...metadataWithSavedIcon( - dialog.document.metadata, - saved.metadata?.lucideIcon, - ), - title: saved.name, - }, - } - : undefined, - } - : dialog, - ); - } catch (cause) { - setPropertiesError(toError(cause)); - throw cause; - } - }, - [mutate, propertiesDialog], - ); - const openPerformerSelection = React.useCallback( async (drill: Drill) => { if (snapshot.status !== "ready") return; @@ -363,7 +325,6 @@ export function useDrillListController() { propertiesDialog, propertiesLoading, propertiesError, - updateProperties, openPerformerSelection, closePerformerSelection, performerDialog, @@ -373,12 +334,3 @@ export function useDrillListController() { remove, } as const; } - -function metadataWithSavedIcon( - metadata: DrillDocument["metadata"], - lucideIcon: string | undefined, -): DrillDocument["metadata"] { - if (lucideIcon !== undefined) return { ...metadata, lucideIcon }; - const { lucideIcon: _ignored, ...withoutIcon } = metadata; - return withoutIcon; -} diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts index 5875de42..71f89d0c 100644 --- a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts +++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts @@ -16,11 +16,17 @@ describe("Field overlay layout", () => { expect(layout.hudStyle.top).toBe(38); expect(layout.hudStyle.left).toBe(24); expect(layout.hudWidth).toBeGreaterThan(0); - expect(layout.liveStyle.right).toBe(24); - expect(layout.dialStyle.right).toBe(24); - expect(Number(layout.dialStyle.top)).toBe( - Number(layout.liveStyle.top) + layout.controlDiameter + layout.controlGap, - ); + const topGap = Number(layout.liveStyle.top) - insets.top; + const betweenGap = + Number(layout.dialStyle.top) - + (Number(layout.liveStyle.top) + layout.controlDiameter); + const bottomGap = + 390 - + insets.bottom - + (Number(layout.dialStyle.top) + layout.controlDiameter); + expect(topGap).toBeCloseTo(layout.controlGap); + expect(betweenGap).toBeCloseTo(layout.controlGap); + expect(bottomGap).toBeCloseTo(layout.controlGap); }); test("centers the live/dial pair above the bottom inset in portrait", () => { @@ -34,20 +40,18 @@ describe("Field overlay layout", () => { expect(layout.controlDiameter).toBeGreaterThanOrEqual(140); expect(layout.controlDiameter).toBeLessThanOrEqual(156); expect(layout.hudStyle.left).toBe(22); - expect(layout.dialStyle.bottom).toBe(32); - expect(layout.liveStyle.left).toBe( - insets.left + - (390 - - insets.left - - insets.right - - (layout.controlDiameter * 2 + layout.controlGap)) / - 2, - ); - expect(Number(layout.dialStyle.left)).toBe( - Number(layout.liveStyle.left) + - layout.controlDiameter + - layout.controlGap, - ); + const leftGap = Number(layout.liveStyle.left) - insets.left; + const betweenGap = + Number(layout.dialStyle.left) - + (Number(layout.liveStyle.left) + layout.controlDiameter); + const rightGap = + 390 - + insets.right - + (Number(layout.dialStyle.left) + layout.controlDiameter); + expect(leftGap).toBeCloseTo(layout.controlGap); + expect(betweenGap).toBeCloseTo(layout.controlGap); + expect(rightGap).toBeCloseTo(layout.controlGap); + expect(layout.dialStyle.bottom).toBe(insets.bottom + layout.controlGap); }); test("shrinks both portrait controls together on a narrow safe width", () => { @@ -57,9 +61,9 @@ describe("Field overlay layout", () => { landscape: false, insets: { top: 20, right: 18, bottom: 20, left: 18 }, }); - const availableWidth = 360 - 18 - 18 - layout.outerPadding * 2; - expect(layout.controlDiameter * 2 + layout.controlGap).toBeLessThanOrEqual( - availableWidth, + const safeWidth = 360 - 18 - 18; + expect(layout.controlDiameter * 2 + layout.controlGap * 3).toBeCloseTo( + safeWidth, ); }); diff --git a/apps/mobile/src/features/field/coordinate-lines-view.tsx b/apps/mobile/src/features/field/coordinate-lines-view.tsx new file mode 100644 index 00000000..5d51d61e --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-lines-view.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { ArrowLeftRight, ArrowUpDown } from "lucide-react-native"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveFonts } from "@eight2five/ui/theme"; + +import type { CoordinateLines } from "./field-hud-state"; + +export function CoordinateLinesView({ + coordinate, + color, + mutedColor, + fontSize = 14, + lineHeight = 17, + iconSize = 13, +}: { + readonly coordinate: CoordinateLines | null; + readonly color: string; + readonly mutedColor: string; + readonly fontSize?: number; + readonly lineHeight?: number; + readonly iconSize?: number; +}) { + if (!coordinate) { + return ( + + – + + ); + } + + return ( + + + + + ); +} + +function CoordinateLine({ + icon, + value, + color, + iconColor, + fontSize, + lineHeight, + iconSize, +}: { + readonly icon: React.ElementType; + readonly value: string; + readonly color: string; + readonly iconColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly iconSize: number; +}) { + return ( + + + + {value} + + + ); +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill.tsx b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx index 489b3f22..d32b9b93 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-pill.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx @@ -7,7 +7,10 @@ import Animated, { } from "react-native-reanimated"; import type { FieldPresetId } from "@eight2five/drill-schema"; import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; -import type { TransitionMetricMode } from "@eight2five/mobile/settings"; +import type { + CoordinateRoundingSteps, + TransitionMetricMode, +} from "@eight2five/mobile/settings"; import { Divider } from "@eight2five/ui/components/divider"; import { Text } from "@eight2five/ui/components/text"; import { @@ -35,6 +38,7 @@ export function DrillPill({ countDisplayMode, metricMode, fieldPreset, + coordinateRoundingSteps, expanded, controlsDisabled, error, @@ -52,6 +56,7 @@ export function DrillPill({ readonly countDisplayMode: CountDisplayMode; readonly metricMode: TransitionMetricMode; readonly fieldPreset: FieldPresetId; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; readonly expanded: boolean; readonly controlsDisabled: boolean; readonly error?: Error; @@ -72,6 +77,7 @@ export function DrillPill({ metricMode, fieldPreset, terminology, + coordinateRoundingSteps, }); const availableListHeight = Math.min( listMaxHeight, @@ -134,6 +140,7 @@ export function DrillPill({ metricMode={metricMode} terminology={terminology} fieldPreset={fieldPreset} + coordinateRoundingSteps={coordinateRoundingSteps} expanded={effectiveExpanded} onSelectIndex={onSelectIndex} /> diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx index f68ec2ba..46ade22c 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx @@ -2,7 +2,10 @@ import React from "react"; import { FlatList, type ListRenderItemInfo } from "react-native"; import { Pressable } from "@eight2five/ui/components/pressable"; import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; -import type { TransitionMetricMode } from "@eight2five/mobile/settings"; +import type { + CoordinateRoundingSteps, + TransitionMetricMode, +} from "@eight2five/mobile/settings"; import type { FieldPresetId } from "@eight2five/drill-schema"; import { useEight2FiveTheme } from "@eight2five/ui/theme"; @@ -13,7 +16,7 @@ import { import { DrillSetMetricGrid } from "./drill-set-metric-grid"; import type { DrillPillColumnMetrics } from "./drill-pill-layout"; -export const DRILL_SET_ROW_HEIGHT = 84; +export const DRILL_SET_ROW_HEIGHT = 104; export function DrillSetList({ pages, @@ -23,6 +26,7 @@ export function DrillSetList({ metricMode, terminology, fieldPreset, + coordinateRoundingSteps, expanded, onSelectIndex, }: { @@ -33,6 +37,7 @@ export function DrillSetList({ readonly metricMode: TransitionMetricMode; readonly terminology: DrillTerminology; readonly fieldPreset: FieldPresetId; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; readonly expanded: boolean; readonly onSelectIndex: (index: number) => void; }) { @@ -56,6 +61,7 @@ export function DrillSetList({ metricMode, fieldPreset, terminology, + coordinateRoundingSteps, }); return ( - - + + Coordinate - - {presentation.coordinate - ? `${presentation.coordinate.side}\n${presentation.coordinate.frontBack}` - : "–"} - + {header && onToggleExpanded ? ( 0, ); - const terms = getDrillTerms(controller.settings.drillTerminology); const palette = React.useMemo( () => ({ canvasBackground: theme.background, @@ -215,6 +214,9 @@ export function FieldScreen({ countDisplayMode={controller.settings.countDisplayMode} metricMode={controller.settings.transitionMetricMode} fieldPreset={controller.fieldPreset} + coordinateRoundingSteps={ + controller.settings.coordinateRoundingSteps + } expanded={canExpandDrillPill && hudState.drillPillExpanded} controlsDisabled={controlsDisabled} error={controller.error} @@ -234,6 +236,9 @@ export function FieldScreen({ width={metrics.hudWidth} live={liveState} fieldPreset={controller.fieldPreset} + coordinateRoundingSteps={ + controller.settings.coordinateRoundingSteps + } onOpenTagConnection={() => setTagDialogOpen(true)} /> ) @@ -252,6 +257,9 @@ export function FieldScreen({ yellowThresholdSteps={ controller.settings.distanceYellowThresholdSteps } + coordinateRoundingSteps={ + controller.settings.coordinateRoundingSteps + } onOpenTagConnection={() => setTagDialogOpen(true)} /> ) @@ -271,7 +279,7 @@ export function FieldScreen({ pageCount={controller.pages.length} terminology={controller.settings.drillTerminology} activeColor={theme.accent} - trackColor={theme.border} + trackColor={colorWithAlpha(theme.accent, "52")} foregroundColor={theme.text} onSelectIndex={(index) => void controller.selectPageAtIndex(index) @@ -288,16 +296,8 @@ export function FieldScreen({ } /> setDrillDialogOpen(false)} - onSelect={(drillId) => { - setDrillDialogOpen(false); - void controller.selectActiveDrill(drillId); - }} /> void; }) { const theme = useEight2FiveTheme(); @@ -71,6 +75,7 @@ export function LivePositionSquare({ live={live} fieldPreset={fieldPreset} compact={diameter < 140} + coordinateRoundingSteps={coordinateRoundingSteps} onOpenTagConnection={onOpenTagConnection} /> @@ -110,11 +115,13 @@ export function LiveOnlyPill({ width, live, fieldPreset, + coordinateRoundingSteps, onOpenTagConnection, }: { readonly width: number; readonly live: FieldLivePositionState; readonly fieldPreset: FieldPresetId; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; readonly onOpenTagConnection: () => void; }) { return ( @@ -126,6 +133,7 @@ export function LiveOnlyPill({ @@ -136,15 +144,21 @@ function LivePositionHeader({ live, fieldPreset, compact = false, + coordinateRoundingSteps, onOpenTagConnection, }: { readonly live: FieldLivePositionState; readonly fieldPreset: FieldPresetId; readonly compact?: boolean; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; readonly onOpenTagConnection: () => void; }) { const theme = useEight2FiveTheme(); - const coordinate = getLiveCoordinateLines(live, fieldPreset); + const coordinate = getLiveCoordinateLines( + live, + fieldPreset, + coordinateRoundingSteps, + ); return ( - - {coordinate ? `${coordinate.side}\n${coordinate.frontBack}` : "–"} - + + + ); } diff --git a/apps/mobile/src/features/settings/anchor-editor-form.ts b/apps/mobile/src/features/settings/anchor-editor-form.ts index ee5f833e..6c691705 100644 --- a/apps/mobile/src/features/settings/anchor-editor-form.ts +++ b/apps/mobile/src/features/settings/anchor-editor-form.ts @@ -1,4 +1,5 @@ import type { FieldPresetId } from "@eight2five/drill-schema"; +import type { CoordinateRoundingSteps } from "@eight2five/mobile/settings"; import { ANCHOR_POSITION_REFERENCE_LABELS, ANCHOR_POSITION_REFERENCES, @@ -148,10 +149,15 @@ export function validateStandardAnchorDraft( export function formatAnchorCanonicalPreview( position: AnchorFieldPosition | undefined, fieldPreset: FieldPresetId = "football-nfhs", + coordinateRoundingSteps: CoordinateRoundingSteps = 0.25, ): { readonly marching: string; readonly meters: string } | undefined { if (!position) return undefined; return { - marching: formatMarchingCoordinate(position, fieldPreset), + marching: formatMarchingCoordinate( + position, + fieldPreset, + coordinateRoundingSteps, + ), meters: `X ${position.xMeters.toFixed(3)} m · Y ${position.yMeters.toFixed(3)} m · Z ${position.zMeters.toFixed(3)} m`, }; } diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx index 91762918..80b3e308 100644 --- a/apps/mobile/src/features/settings/anchor-editor-screen.tsx +++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx @@ -3,7 +3,6 @@ import { Radio, Save, TriangleAlert } from "lucide-react-native"; import { Button, ButtonIcon, - ButtonSpinner, ButtonText, } from "@eight2five/ui/components/button"; import { Card } from "@eight2five/ui/components/card"; @@ -24,6 +23,7 @@ import { AnchorNumberInput, StandardAnchorPositionForm, } from "./standard-anchor-position-form"; +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; import { useAnchorEditorController } from "./use-anchor-editor-controller"; import { SettingsMessage, @@ -42,6 +42,7 @@ export function AnchorEditorScreen({ const preview = formatAnchorCanonicalPreview( controller.validation.position, controller.fieldPreset, + controller.coordinateRoundingSteps, ); if (!controller.developerModeEnabled) { @@ -210,7 +211,7 @@ export function AnchorEditorScreen({ } }} > - {controller.saving ? : } + {controller.saving ? : } Save Anchor Position diff --git a/apps/mobile/src/features/settings/anchor-list-screen.tsx b/apps/mobile/src/features/settings/anchor-list-screen.tsx index 86079ce8..52650705 100644 --- a/apps/mobile/src/features/settings/anchor-list-screen.tsx +++ b/apps/mobile/src/features/settings/anchor-list-screen.tsx @@ -3,7 +3,6 @@ import { Database, Pencil, RefreshCw, Triangle } from "lucide-react-native"; import { Button, ButtonIcon, - ButtonSpinner, ButtonText, } from "@eight2five/ui/components/button"; import { HStack } from "@eight2five/ui/components/hstack"; @@ -13,6 +12,7 @@ import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; import { useAnchorListController } from "./use-anchor-list-controller"; import { SettingsMessage, @@ -58,7 +58,7 @@ export function AnchorListScreen() { onPress={() => void controller.refresh()} > {controller.refreshing ? ( - + ) : ( )} diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx index 412e72ae..f614cc36 100644 --- a/apps/mobile/src/features/settings/developer-settings-screen.tsx +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -18,7 +18,6 @@ import { import { Button, ButtonIcon, - ButtonSpinner, ButtonText, } from "@eight2five/ui/components/button"; import { HStack } from "@eight2five/ui/components/hstack"; @@ -30,6 +29,7 @@ import { useEight2FiveTheme, } from "@eight2five/ui/theme"; +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; import { useAppSettingsSnapshot, useAppSettingsStore, @@ -197,7 +197,7 @@ export function DeveloperSettingsScreen() { onPress={confirmDatabaseRebuild} > {rebuildingDatabase ? ( - + ) : ( )} @@ -267,7 +267,11 @@ export function DeveloperSettingsScreen() { isDisabled={pans.connectionState !== "connected" || refreshing} onPress={() => void refresh()} > - {refreshing ? : } + {refreshing ? ( + + ) : ( + + )} Refresh Hardware Diagnostics @@ -398,7 +402,7 @@ export function DeveloperSettingsScreen() { onPress={confirmDatabaseRebuild} > {rebuildingDatabase ? ( - + ) : ( )} diff --git a/apps/mobile/src/features/settings/network-detail-screen.tsx b/apps/mobile/src/features/settings/network-detail-screen.tsx index be835995..83773ae5 100644 --- a/apps/mobile/src/features/settings/network-detail-screen.tsx +++ b/apps/mobile/src/features/settings/network-detail-screen.tsx @@ -17,7 +17,6 @@ import { import { Button, ButtonIcon, - ButtonSpinner, ButtonText, } from "@eight2five/ui/components/button"; import { HStack } from "@eight2five/ui/components/hstack"; @@ -37,6 +36,7 @@ import { validateNetworkDraft, type NetworkDraft, } from "./network-form"; +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; import { NetworkProfileForm } from "./network-profile-form"; import { anchorInitiatorLabel, @@ -369,7 +369,7 @@ export function NetworkDetailScreen({ isDisabled={busy} onPress={confirmDelete} > - {busy ? : } + {busy ? : } Delete Network diff --git a/apps/mobile/src/features/settings/network-profile-form.tsx b/apps/mobile/src/features/settings/network-profile-form.tsx index 6df12e72..f530c000 100644 --- a/apps/mobile/src/features/settings/network-profile-form.tsx +++ b/apps/mobile/src/features/settings/network-profile-form.tsx @@ -12,13 +12,13 @@ import { Input, InputField } from "@eight2five/ui/components/input"; import { Button, ButtonIcon, - ButtonSpinner, ButtonText, } from "@eight2five/ui/components/button"; import { Save } from "lucide-react-native"; import { VStack } from "@eight2five/ui/components/vstack"; import { eight2FiveSpacing } from "@eight2five/ui/theme"; +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; import type { NetworkDraft, NetworkDraftErrors } from "./network-form"; export function NetworkProfileForm({ @@ -97,7 +97,7 @@ export function NetworkProfileForm({ isDisabled={saving} onPress={onSubmit} > - {saving ? : } + {saving ? : } {submitLabel} diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index 2d855fe6..c543138e 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -14,10 +14,12 @@ import { Rows3, RulerDimensionLine, } from "lucide-react-native"; -import type { - AppearanceMode, - AppSettingsUpdate, - FieldPerspective, +import { + COORDINATE_ROUNDING_PRESETS, + type AppearanceMode, + type AppSettingsUpdate, + type CoordinateRoundingSteps, + type FieldPerspective, } from "@eight2five/mobile/settings"; import type { DrillTerminology } from "@eight2five/mobile/drill"; import { @@ -66,6 +68,20 @@ const FIELD_PRESET_CHOICES = FIELD_PRESET_IDS.map((value) => ({ value, })) satisfies readonly { label: string; value: FieldPresetId }[]; +const COORDINATE_ROUNDING_CHOICES = COORDINATE_ROUNDING_PRESETS.map( + (value) => ({ + label: + value === 0.125 + ? "⅛ step" + : value === 0.25 + ? "¼ step" + : value === 0.5 + ? "½ step" + : "1 step", + value: String(value), + }), +); + const TRANSITION_COUNT_CHOICES = Array.from({ length: 6 }, (_, count) => ({ label: String(count), value: String(count), @@ -188,10 +204,24 @@ export function SettingsScreen() { disabled={disabled} testID="field-perspective-setting" /> + + icon={RulerDimensionLine} + title="Coordinate rounding" + description="Round displayed marching coordinates to this step increment." + value={String(settings.coordinateRoundingSteps)} + choices={COORDINATE_ROUNDING_CHOICES} + onChange={(value) => + void update({ + coordinateRoundingSteps: Number(value) as CoordinateRoundingSteps, + }) + } + disabled={disabled} + testID="coordinate-rounding-setting" + /> void update({ showAuxiliaryFieldMarks }) diff --git a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts index 57d86f36..4a372b72 100644 --- a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts +++ b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts @@ -191,6 +191,7 @@ export function useAnchorEditorController(anchorId: string) { canWritePosition, anchor, fieldPreset, + coordinateRoundingSteps: settings.settings.coordinateRoundingSteps, mode, marchingDraft, standardDraft, diff --git a/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts b/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts index 571d4309..918dc019 100644 --- a/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts +++ b/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts @@ -52,11 +52,15 @@ describe("drill icon shape policy", () => { ); }); - test("uses opposite Y scaling for screen-constant upright labels", () => { - expect(getDrillLabelTransformPolicy(0.25)).toEqual({ + test("counter-scales labels for both camera perspectives", () => { + expect(getDrillLabelTransformPolicy(0.25, "director")).toEqual({ scaleX: 0.25, scaleY: -0.25, }); + expect(getDrillLabelTransformPolicy(0.25, "performer")).toEqual({ + scaleX: -0.25, + scaleY: 0.25, + }); }); test("keeps dot and circle primitives circular", () => { diff --git a/packages/mobile/src/field/__tests__/field-camera-math.test.ts b/packages/mobile/src/field/__tests__/field-camera-math.test.ts index 2af873f9..200bf11e 100644 --- a/packages/mobile/src/field/__tests__/field-camera-math.test.ts +++ b/packages/mobile/src/field/__tests__/field-camera-math.test.ts @@ -87,7 +87,7 @@ describe("field camera math", () => { expect(preserved.y).toBeCloseTo(focal.y, 10); }); - test("clamps using the visible half span and centers oversized viewports", () => { + test("clamps only the camera center even when the viewport is oversized", () => { const bounds = { minXMeters: 0, maxXMeters: 100, @@ -101,8 +101,8 @@ describe("field camera math", () => { bounds, ), ).toEqual({ - centerXMeters: 40, - centerYMeters: 30, + centerXMeters: 0, + centerYMeters: 50, metersPerPixel: 0.1, }); expect( @@ -111,7 +111,7 @@ describe("field camera math", () => { size, bounds, ), - ).toMatchObject({ centerXMeters: 50, centerYMeters: 25 }); + ).toMatchObject({ centerXMeters: 10, centerYMeters: 10 }); }); test("clamps panning to the exterior camera allowance in both orientations", () => { @@ -129,14 +129,8 @@ describe("field camera math", () => { bounds, ); - expect(clamped.centerXMeters).toBeCloseTo( - bounds.minXMeters + (currentSize.width * metersPerPixel) / 2, - 10, - ); - expect(clamped.centerYMeters).toBeCloseTo( - bounds.maxYMeters - (currentSize.height * metersPerPixel) / 2, - 10, - ); + expect(clamped.centerXMeters).toBeCloseTo(bounds.minXMeters, 10); + expect(clamped.centerYMeters).toBeCloseTo(bounds.maxYMeters, 10); } }); diff --git a/packages/mobile/src/field/__tests__/field-paths.test.ts b/packages/mobile/src/field/__tests__/field-paths.test.ts index 2c2bd30c..1e1889ef 100644 --- a/packages/mobile/src/field/__tests__/field-paths.test.ts +++ b/packages/mobile/src/field/__tests__/field-paths.test.ts @@ -215,8 +215,9 @@ describe("aggregate field paths", () => { tickLengthMeters: field.fieldDefinition.markings.inboundsHashMarks.lengthMeters, }); - expect(subpathCount(paths.hashGuideLinesPath)).toBe(2); - expect(paths.counts.hashGuideLines.lineCount).toBe(2); + expect(paths.hashGuideLinesPath).toBe(""); + expect(subpathCount(paths.hashGuideLinesPath)).toBe(0); + expect(paths.counts.hashGuideLines.lineCount).toBe(0); expect(subpathCount(paths.boundaryPath)).toBe(1); expect(paths.boundaryPath.endsWith(" Z")).toBe(true); expect(paths.counts.boundary.segmentCount).toBe(1); @@ -264,9 +265,11 @@ describe("aggregate field paths", () => { expect(front.y1 - template.bounds.minYMeters).toBeCloseTo( markings.insetFromSidelineMeters, ); + expect(front.y2 - front.y1).toBeCloseTo(markings.lengthMeters); expect(template.bounds.maxYMeters - back.y2).toBeCloseTo( markings.insetFromSidelineMeters, ); + expect(back.y2 - back.y1).toBeCloseTo(markings.lengthMeters); }, ); }); diff --git a/packages/mobile/src/field/camera/field-camera-math.ts b/packages/mobile/src/field/camera/field-camera-math.ts index 5f37f901..428f6e76 100644 --- a/packages/mobile/src/field/camera/field-camera-math.ts +++ b/packages/mobile/src/field/camera/field-camera-math.ts @@ -112,13 +112,13 @@ export function clampFieldCameraAxis( center: number, minimum: number, maximum: number, - halfVisibleSpan: number, + _halfVisibleSpan = 0, ): number { "worklet"; - const minimumCenter = minimum + halfVisibleSpan; - const maximumCenter = maximum - halfVisibleSpan; - if (minimumCenter > maximumCenter) return (minimum + maximum) / 2; - return Math.min(maximumCenter, Math.max(minimumCenter, center)); + // Clamp only the camera center. A zoomed-out viewport is intentionally + // allowed to extend beyond the camera bounds; otherwise a large viewport + // collapses the valid pan range to a single centered point. + return Math.min(maximum, Math.max(minimum, center)); } export function clampFieldViewport( diff --git a/packages/mobile/src/field/marching.ts b/packages/mobile/src/field/marching.ts index 06fd8be7..3063b00c 100644 --- a/packages/mobile/src/field/marching.ts +++ b/packages/mobile/src/field/marching.ts @@ -19,6 +19,7 @@ import type { StandardFootballFieldTemplate } from "./template"; const EPSILON = 1e-9; const NFHS_FIELD = getFieldPreset("football-nfhs"); +export const DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS = 0.25; export type MarchingFieldInput = | FieldPresetId @@ -70,18 +71,30 @@ function assertFinite(value: number, name: string): void { } /** - * Marching labels are intentionally quarter-step friendly. The canonical - * coordinate retains the unrounded value; only this display helper rounds it. + * Round marching-coordinate display values without mutating canonical drill + * coordinates. The app preference controls the increment; quarter-step + * rounding remains the default for callers that do not provide one. */ -export function formatMarchingSteps(steps: number): string { +export function formatMarchingSteps( + steps: number, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { assertFinite(steps, "Steps"); - const quarterSteps = Math.round(steps * 4) / 4; - const cleaned = Math.abs(quarterSteps) < EPSILON ? 0 : quarterSteps; - return Number(cleaned.toFixed(2)).toString(); + assertFinite(roundingSteps, "Coordinate rounding"); + if (roundingSteps <= 0) { + throw new RangeError("Coordinate rounding must be greater than zero."); + } + const rounded = Math.round(steps / roundingSteps) * roundingSteps; + const cleaned = Math.abs(rounded) < EPSILON ? 0 : rounded; + return Number(cleaned.toFixed(3)).toString(); } -function stepWord(steps: number, uppercase = true): string { - const value = formatMarchingSteps(steps); +function stepWord( + steps: number, + uppercase = true, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + const value = formatMarchingSteps(steps, roundingSteps); if (Math.abs(Number(value)) === 1) return uppercase ? "One Step" : "one step"; return uppercase ? `${value} Steps` : `${value} steps`; } @@ -431,14 +444,17 @@ export const marchingToFieldPoint = marchingCoordinateToFieldPoint; export const fieldPositionToMarchingCoordinate = fieldPointToMarchingCoordinate; export const marchingCoordinateToFieldPosition = marchingCoordinateToFieldPoint; -function formatSideCoordinate(coordinate: MarchingSideCoordinate): string { +function formatSideCoordinate( + coordinate: MarchingSideCoordinate, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { const line = yardLineText(coordinate.yardLine); if (coordinate.relation === "on") { return coordinate.side === "center" ? `On ${line}` : `Side ${coordinate.side}: On ${line}`; } - const steps = stepWord(coordinate.offsetSteps); + const steps = stepWord(coordinate.offsetSteps, true, roundingSteps); if (coordinate.side === "center") return `On ${line}`; return `Side ${coordinate.side}: ${steps} ${coordinate.relation} ${line}`; } @@ -481,16 +497,20 @@ function lateralReferenceText( function formatFrontBackCoordinate( coordinate: MarchingFrontBackCoordinate, field: ResolvedFieldDefinition, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, ): string { const reference = lateralReferenceText(coordinate.reference, field); if (coordinate.relation === "on") return `On ${reference}`; - return `${stepWord(coordinate.offsetSteps)} ${ + return `${stepWord(coordinate.offsetSteps, true, roundingSteps)} ${ coordinate.relation === "behind" ? "behind" : "in front of" } ${reference}`; } -export function formatMarchingSide(coordinate: MarchingSideCoordinate): string { - return formatSideCoordinate(coordinate); +export function formatMarchingSide( + coordinate: MarchingSideCoordinate, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + return formatSideCoordinate(coordinate, roundingSteps); } export const formatMarchingSideCoordinate = formatMarchingSide; @@ -498,10 +518,12 @@ export const formatMarchingSideCoordinate = formatMarchingSide; export function formatMarchingFrontBack( coordinate: MarchingFrontBackCoordinate, fieldInput: MarchingFieldInput = NFHS_FIELD, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, ): string { return formatFrontBackCoordinate( coordinate, resolveMarchingField(fieldInput), + roundingSteps, ); } @@ -510,6 +532,7 @@ export const formatMarchingFrontBackCoordinate = formatMarchingFrontBack; export function formatMarchingCoordinate( coordinateOrPoint: MarchingCoordinate | FieldPoint | DrillGridPoint, fieldInput: MarchingFieldInput = NFHS_FIELD, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, ): string { const field = resolveMarchingField(fieldInput); let coordinate: MarchingCoordinate; @@ -521,8 +544,8 @@ export function formatMarchingCoordinate( coordinate = fieldPointToMarchingCoordinate(coordinateOrPoint, field); } const parts = [ - formatSideCoordinate(coordinate.side), - formatFrontBackCoordinate(coordinate.frontBack, field), + formatSideCoordinate(coordinate.side, roundingSteps), + formatFrontBackCoordinate(coordinate.frontBack, field, roundingSteps), ]; const formatted = parts.join("; "); return coordinate.outOfBounds?.length diff --git a/packages/mobile/src/field/render/create-field-paths.ts b/packages/mobile/src/field/render/create-field-paths.ts index 22f063d1..189fbc55 100644 --- a/packages/mobile/src/field/render/create-field-paths.ts +++ b/packages/mobile/src/field/render/create-field-paths.ts @@ -52,7 +52,7 @@ export interface HashMarksPathMetadata { } export interface HashGuideLinesPathMetadata { - readonly lineCount: 2; + readonly lineCount: 0; } export interface SidelineHashMarksPathMetadata { @@ -222,15 +222,10 @@ export function createFieldPaths( } } const hashMarksPath = hashMarks.join(" "); - const hashGuideLinesPath = template.hashLines - .map((line) => - horizontalSegment( - fieldExtent.minXMeters, - line.coordinateMeters, - fieldExtent.maxXMeters, - ), - ) - .join(" "); + // A football hash is a sequence of discrete two-foot ticks, never a + // continuous guide line across the field. Keep the legacy path slot empty + // so older render consumers cannot accidentally resurrect that artifact. + const hashGuideLinesPath = ""; const sidelineMarkings = template.fieldDefinition.markings.sidelineHashMarks; const sidelineXCoordinates = spacedInteriorCoordinates( @@ -294,7 +289,7 @@ export function createFieldPaths( ticksPerRow: inboundsXCoordinates.length, tickCount: hashMarks.length, }), - hashGuideLines: Object.freeze({ lineCount: 2 }), + hashGuideLines: Object.freeze({ lineCount: 0 }), sidelineHashMarks: Object.freeze({ spacingMeters: sidelineMarkings.spacingMeters, markLengthMeters: sidelineMarkings.lengthMeters, diff --git a/packages/mobile/src/field/render/drill-shape-policy.ts b/packages/mobile/src/field/render/drill-shape-policy.ts index ae1974d6..ece920af 100644 --- a/packages/mobile/src/field/render/drill-shape-policy.ts +++ b/packages/mobile/src/field/render/drill-shape-policy.ts @@ -1,4 +1,5 @@ import type { EntityIcon } from "@eight2five/drill-schema"; +import type { FieldCameraPerspective } from "../camera/field-camera-types"; /** Icons that can be rendered as a directional path or as a circle. */ export type DrillShapeIcon = EntityIcon | "circle"; @@ -39,12 +40,12 @@ export interface DrillLabelTransformPolicy { */ export function getDrillLabelTransformPolicy( metersPerPixel: number, + perspective: FieldCameraPerspective = "director", ): DrillLabelTransformPolicy { "worklet"; - return { - scaleX: metersPerPixel, - scaleY: -metersPerPixel, - }; + return perspective === "performer" + ? { scaleX: -metersPerPixel, scaleY: metersPerPixel } + : { scaleX: metersPerPixel, scaleY: -metersPerPixel }; } /** diff --git a/packages/mobile/src/field/render/field-drill-layer.tsx b/packages/mobile/src/field/render/field-drill-layer.tsx index cc6d207e..983d766b 100644 --- a/packages/mobile/src/field/render/field-drill-layer.tsx +++ b/packages/mobile/src/field/render/field-drill-layer.tsx @@ -20,6 +20,7 @@ import type { PhysicalImmediateTransition, PhysicalTransitionPathGeometry, } from "../../drill/render-scene"; +import type { FieldCameraPerspective } from "../camera/field-camera-types"; import type { FieldPoint } from "../types"; import { resolveCurrentTargetPosition } from "./field-overlay-types"; import { @@ -31,7 +32,7 @@ import { import type { FieldRenderPalette } from "./field-render-tokens"; import { DRILL_MARKER_COLORS, - DRILL_MARKER_SIZE_METERS, + DRILL_MARKER_SIZE_PIXELS, } from "./field-render-tokens"; const EMPTY_ENTITIES: readonly DrillRenderEntity[] = Object.freeze([]); @@ -46,8 +47,8 @@ const LABEL_FONT_SIZE_PX = 12; const LABEL_LINE_HEIGHT_PX = 14; const MARKER_STROKE_PX = 2; const CONNECTOR_STROKE_PX = 1.25; -const DASH_LENGTH_PX = 6; -const DASH_GAP_PX = 4; +const DASH_LENGTH_PX = 2.4; +const DASH_GAP_PX = 1.6; const EXTRA_TRANSITION_OPACITY = 0.68; export interface FieldDrillLayerProps { @@ -56,6 +57,7 @@ export interface FieldDrillLayerProps { readonly fallbackTargetPosition?: FieldPoint; readonly metersPerPixel: SharedValue; readonly palette: FieldRenderPalette; + readonly perspective: FieldCameraPerspective; } /** @@ -68,6 +70,7 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ fallbackTargetPosition, metersPerPixel, palette, + perspective, }: FieldDrillLayerProps) { const labelFont = useFont(Montserrat_400Regular, LABEL_FONT_SIZE_PX); const entities = scene?.entities ?? EMPTY_ENTITIES; @@ -90,6 +93,7 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ labelFont={labelFont} metersPerPixel={metersPerPixel} palette={palette} + perspective={perspective} /> ))} {previousConnectors.map((transition) => ( @@ -113,6 +117,7 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ key={`previous-dot-${dot.setId}`} point={dot.point} color={DRILL_MARKER_COLORS.red} + metersPerPixel={metersPerPixel} /> ))} {nextDots.map((dot) => ( @@ -120,6 +125,7 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ key={`next-dot-${dot.setId}`} point={dot.point} color={DRILL_MARKER_COLORS.green} + metersPerPixel={metersPerPixel} /> ))} {scene?.previous ? ( @@ -151,11 +157,13 @@ function OrdinaryEntity({ labelFont, metersPerPixel, palette, + perspective, }: { readonly entity: DrillRenderEntity; readonly labelFont: SkFont | null; readonly metersPerPixel: SharedValue; readonly palette: FieldRenderPalette; + readonly perspective: FieldCameraPerspective; }) { const icon = entity.icon as string; const width = @@ -227,6 +235,7 @@ function OrdinaryEntity({ font={labelFont} metersPerPixel={metersPerPixel} color={palette.fieldLines} + perspective={perspective} /> ); @@ -237,11 +246,13 @@ function EntityLabel({ font, metersPerPixel, color, + perspective, }: { readonly entity: DrillRenderEntity; readonly font: SkFont | null; readonly metersPerPixel: SharedValue; readonly color: string; + readonly perspective: FieldCameraPerspective; }) { const lines = React.useMemo( () => @@ -252,15 +263,11 @@ function EntityLabel({ [entity.labelText, entity.nameText], ); const labelTransform = useDerivedValue(() => { - const labelScale = getDrillLabelTransformPolicy(metersPerPixel.value); - return [ - { translateX: entity.position.xMeters }, - { translateY: entity.position.yMeters }, - { scaleX: labelScale.scaleX }, - // The camera has a negative Y scale. This restores upright screen text - // and makes the label size independent of zoom. - { scaleY: labelScale.scaleY }, - ]; + const labelScale = getDrillLabelTransformPolicy( + metersPerPixel.value, + perspective, + ); + return [{ scaleX: labelScale.scaleX }, { scaleY: labelScale.scaleY }]; }); if (!font || lines.length === 0) return null; @@ -268,12 +275,16 @@ function EntityLabel({ const startY = -LABEL_LINE_HEIGHT_PX * (lines.length + 0.15); return ( - + {lines.map((line, index) => ( ; }) { + const radius = useDerivedValue( + () => + (metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.midpointDiameter) / 2, + ); return ( @@ -347,18 +364,18 @@ function ImmediateTransitionLayer({ () => createPhysicalPath(transition.geometry), [transition.geometry], ); - const markerDiameter = DRILL_MARKER_SIZE_METERS.transitionDiameter; - const markerPath = React.useMemo( - () => createCirclePath(markerDiameter / 2), - [markerDiameter], - ); const markerPoint = kind === "previous" ? transition.start : transition.end; - const markerTransform = React.useMemo( - () => [ - { translateX: markerPoint.xMeters }, - { translateY: markerPoint.yMeters }, - ], - [markerPoint.xMeters, markerPoint.yMeters], + const markerRadius = useDerivedValue( + () => + (metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.transitionDiameter) / 2, + ); + const midpointRadius = useDerivedValue( + () => + (metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.midpointDiameter) / 2, + ); + const centerRadius = useDerivedValue( + () => + metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.transitionDiameter * 0.18, ); const markerStrokeWidth = useDerivedValue( () => metersPerPixel.value * MARKER_STROKE_PX, @@ -372,7 +389,6 @@ function ImmediateTransitionLayer({ ]); const connectorColor = kind === "previous" ? DRILL_MARKER_COLORS.red : DRILL_MARKER_COLORS.green; - const centerRadius = markerDiameter * 0.18; return ( <> @@ -384,39 +400,46 @@ function ImmediateTransitionLayer({ strokeCap="round" strokeJoin="round" /> - - {kind === "previous" ? ( - + + + ) : ( + <> + + - - - ) : ( - <> - - - - )} - - + /> + + )} + @@ -430,35 +453,35 @@ function CurrentTargetMarker({ readonly point: PhysicalFieldPoint | FieldPoint; readonly metersPerPixel: SharedValue; }) { - const diameter = DRILL_MARKER_SIZE_METERS.currentDiameter; - const ringPath = React.useMemo( - () => createCirclePath(diameter / 2), - [diameter], + const radius = useDerivedValue( + () => (metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.currentDiameter) / 2, ); - const transform = React.useMemo( - () => [{ translateX: point.xMeters }, { translateY: point.yMeters }], - [point.xMeters, point.yMeters], + const centerRadius = useDerivedValue( + () => + metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.currentDiameter * 0.14, ); const strokeWidth = useDerivedValue( () => metersPerPixel.value * MARKER_STROKE_PX, ); return ( - + <> {/* The ring is intentionally not filled; its interior stays transparent. */} - - + ); } @@ -495,10 +518,6 @@ function createPhysicalPath(geometry: PhysicalTransitionPathGeometry): SkPath { return builder.build(); } -function createCirclePath(radius: number): SkPath { - return Skia.PathBuilder.Make().addCircle(0, 0, radius).build(); -} - function createShapePath( points: readonly { readonly x: number; readonly y: number }[], ): SkPath { diff --git a/packages/mobile/src/field/render/field-position-layer.tsx b/packages/mobile/src/field/render/field-position-layer.tsx index cd1f0a6b..fbb4d6e8 100644 --- a/packages/mobile/src/field/render/field-position-layer.tsx +++ b/packages/mobile/src/field/render/field-position-layer.tsx @@ -1,4 +1,4 @@ -import { Circle, Group } from "@shopify/react-native-skia"; +import { Circle } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import type { FieldPoint } from "../types"; @@ -13,25 +13,29 @@ export function FieldPositionLayer({ readonly metersPerPixel: SharedValue; readonly palette: FieldRenderPalette; }) { - const liveTransform = useDerivedValue(() => { - const position = livePosition.value; - const scale = metersPerPixel.value; - return [ - { translateX: position?.xMeters ?? -1_000_000 }, - { translateY: position?.yMeters ?? -1_000_000 }, - { scaleX: scale }, - { scaleY: -scale }, - ]; - }); + const cx = useDerivedValue(() => livePosition.value?.xMeters ?? -1_000_000); + const cy = useDerivedValue(() => livePosition.value?.yMeters ?? -1_000_000); + const outerRadius = useDerivedValue(() => metersPerPixel.value * 9); + const innerRadius = useDerivedValue(() => metersPerPixel.value * 7); const liveOpacity = useDerivedValue(() => livePosition.value === null ? 0 : 1, ); return ( <> - - - - + + ); } diff --git a/packages/mobile/src/field/render/field-render-tokens.ts b/packages/mobile/src/field/render/field-render-tokens.ts index 02a44cb8..468709e4 100644 --- a/packages/mobile/src/field/render/field-render-tokens.ts +++ b/packages/mobile/src/field/render/field-render-tokens.ts @@ -38,7 +38,14 @@ export const DRILL_MARKER_SIZE_STEPS = Object.freeze({ midpointDiameter: 0.375, }); -/** Physical marker sizes keep their world meaning while the camera zooms. */ +/** Fixed screen-space marker diameters used by the interactive field HUD. */ +export const DRILL_MARKER_SIZE_PIXELS = Object.freeze({ + currentDiameter: 16, + transitionDiameter: 8, + midpointDiameter: 4, +}); + +/** Legacy physical equivalents retained for non-render calculations/tests. */ export const DRILL_MARKER_SIZE_METERS = Object.freeze({ currentDiameter: DRILL_MARKER_SIZE_STEPS.currentDiameter * STANDARD_STEP_METERS, diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx index 8b9755e0..0fe15ccf 100644 --- a/packages/mobile/src/field/render/field-scene.tsx +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -75,6 +75,7 @@ export function FieldScene({ paths={paths} metersPerPixel={camera.metersPerPixel} palette={palette} + perspective={perspective} showPerimeterStepGrid={showPerimeterStepGrid} showAuxiliaryFieldMarks={showAuxiliaryFieldMarks} /> @@ -89,6 +90,7 @@ export function FieldScene({ fallbackTargetPosition={targetPosition} metersPerPixel={camera.metersPerPixel} palette={palette} + perspective={perspective} /> {guidanceVisible && targetPosition ? ( ; readonly palette: FieldRenderPalette; + readonly perspective: FieldCameraPerspective; readonly showPerimeterStepGrid: boolean; readonly showAuxiliaryFieldMarks: boolean; } @@ -24,6 +35,7 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ paths, metersPerPixel, palette, + perspective, showPerimeterStepGrid, showAuxiliaryFieldMarks, }: FieldStaticLayerProps) { @@ -39,6 +51,10 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ Montserrat_600SemiBold, YARD_NUMBER_MEASUREMENT_FONT_SIZE, ); + const sidelineFont = useFont( + Montserrat_600SemiBold, + SIDELINE_LABEL_FONT_SIZE_PX, + ); const fieldClip = { x: template.bounds.minXMeters, y: template.bounds.minYMeters, @@ -95,22 +111,13 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ strokeWidth={fieldLineStroke} /> {showAuxiliaryFieldMarks ? ( - <> - - - + ) : null} + {sidelineFont ? ( + <> + + + + ) : null} {numberFont ? template.yardNumbers.map((number) => { const layout = createYardNumberTextLayout( @@ -154,3 +183,44 @@ export const FieldStaticLayer = React.memo(function FieldStaticLayer({ ); }); + +function SidelineLabel({ + text, + yMeters, + atTop, + perspective, + metersPerPixel, + font, + color, +}: { + readonly text: string; + readonly yMeters: number; + readonly atTop: boolean; + readonly perspective: FieldCameraPerspective; + readonly metersPerPixel: SharedValue; + readonly font: SkFont; + readonly color: string; +}) { + const width = font.measureText(text).width; + const transform = useDerivedValue(() => { + const scale = metersPerPixel.value; + return perspective === "performer" + ? [{ scaleX: -scale }, { scaleY: scale }] + : [{ scaleX: scale }, { scaleY: -scale }]; + }); + const baselineOffset = atTop + ? SIDELINE_LABEL_FONT_SIZE_PX + SIDELINE_LABEL_INSET_PX + : -SIDELINE_LABEL_INSET_PX; + + return ( + + + + ); +} diff --git a/packages/mobile/src/settings/SqliteSettingsRepository.ts b/packages/mobile/src/settings/SqliteSettingsRepository.ts index 133436c3..496e32a0 100644 --- a/packages/mobile/src/settings/SqliteSettingsRepository.ts +++ b/packages/mobile/src/settings/SqliteSettingsRepository.ts @@ -49,6 +49,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { default_field_preset = ?, transition_metric_mode = ?, count_display_mode = ?, + coordinate_rounding_steps = ?, guidance_enabled = ?, developer_mode_enabled = ?, show_cached_anchor_geometry = ?, @@ -79,6 +80,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { DEFAULT_APP_SETTINGS.defaultFieldPreset, DEFAULT_APP_SETTINGS.transitionMetricMode, DEFAULT_APP_SETTINGS.countDisplayMode, + DEFAULT_APP_SETTINGS.coordinateRoundingSteps, boolToSql(DEFAULT_APP_SETTINGS.guidanceEnabled), boolToSql(DEFAULT_APP_SETTINGS.developerModeEnabled), boolToSql(DEFAULT_APP_SETTINGS.showCachedAnchorGeometry), @@ -116,6 +118,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { default_field_preset, transition_metric_mode, count_display_mode, + coordinate_rounding_steps, guidance_enabled, developer_mode_enabled, show_cached_anchor_geometry, @@ -157,6 +160,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { default_field_preset, transition_metric_mode, count_display_mode, + coordinate_rounding_steps, guidance_enabled, developer_mode_enabled, show_cached_anchor_geometry, @@ -180,7 +184,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { comfortable_anchor_range_meters, active_drill_id, selected_drill_page_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton_id) DO UPDATE SET appearance_mode = excluded.appearance_mode, drill_features_enabled = excluded.drill_features_enabled, @@ -189,6 +193,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { default_field_preset = excluded.default_field_preset, transition_metric_mode = excluded.transition_metric_mode, count_display_mode = excluded.count_display_mode, + coordinate_rounding_steps = excluded.coordinate_rounding_steps, guidance_enabled = excluded.guidance_enabled, developer_mode_enabled = excluded.developer_mode_enabled, show_cached_anchor_geometry = excluded.show_cached_anchor_geometry, @@ -221,6 +226,7 @@ export class SqliteSettingsRepository implements AppSettingsRepository { normalized.defaultFieldPreset, normalized.transitionMetricMode, normalized.countDisplayMode, + normalized.coordinateRoundingSteps, boolToSql(normalized.guidanceEnabled), boolToSql(normalized.developerModeEnabled), boolToSql(normalized.showCachedAnchorGeometry), @@ -263,6 +269,7 @@ function fromRow(row: AppSettingsRow): AppSettings { defaultFieldPreset: row.default_field_preset, transitionMetricMode: row.transition_metric_mode, countDisplayMode: row.count_display_mode, + coordinateRoundingSteps: row.coordinate_rounding_steps, guidanceEnabled: sqliteBoolean(row.guidance_enabled), developerModeEnabled: sqliteBoolean(row.developer_mode_enabled), showCachedAnchorGeometry: sqliteBoolean(row.show_cached_anchor_geometry), @@ -300,6 +307,7 @@ function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { row.default_field_preset === settings.defaultFieldPreset && row.transition_metric_mode === settings.transitionMetricMode && row.count_display_mode === settings.countDisplayMode && + row.coordinate_rounding_steps === settings.coordinateRoundingSteps && row.guidance_enabled === boolToSql(settings.guidanceEnabled) && row.developer_mode_enabled === boolToSql(settings.developerModeEnabled) && row.show_cached_anchor_geometry === diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts index 54626a65..bb4b0907 100644 --- a/packages/mobile/src/settings/__tests__/repository.test.ts +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -22,6 +22,7 @@ describe("app settings", () => { default_field_preset: "football-nfhs", transition_metric_mode: "step-size", count_display_mode: "counts", + coordinate_rounding_steps: 0.25, guidance_enabled: 1, developer_mode_enabled: 0, show_cached_anchor_geometry: 0, @@ -57,6 +58,7 @@ describe("app settings", () => { default_field_preset: "unknown", transition_metric_mode: "unknown", count_display_mode: "unknown", + coordinate_rounding_steps: 0.3, guidance_enabled: "yes", developer_mode_enabled: 0, show_cached_anchor_geometry: 1, @@ -176,6 +178,7 @@ describe("app settings", () => { defaultFieldPreset: "football-ncaa", transitionMetricMode: "crossing-counts", countDisplayMode: "measures", + coordinateRoundingSteps: 0.5, guidanceEnabled: false, developerModeEnabled: true, showCachedAnchorGeometry: true, @@ -204,6 +207,7 @@ describe("app settings", () => { appearanceMode: "dark", drillTerminology: "pages", countDisplayMode: "measures", + coordinateRoundingSteps: 0.5, previousTransitionSetCount: 0, nextTransitionSetCount: 5, distanceGreenThresholdSteps: 0.75, @@ -225,6 +229,7 @@ describe("app settings", () => { field_perspective: "performer", default_field_preset: "football-nfl", transition_metric_mode: "crossing-counts", + coordinate_rounding_steps: 1, guidance_enabled: 0, developer_mode_enabled: 1, show_cached_anchor_geometry: 1, @@ -276,6 +281,15 @@ describe("app settings", () => { normalizeAppSettings({ drillTerminology: "pages" }).drillTerminology, ).toBe("pages"); expect(normalizeAppSettings({}).fieldPerspective).toBe("performer"); + expect(normalizeAppSettings({}).coordinateRoundingSteps).toBe(0.25); + expect( + normalizeAppSettings({ coordinateRoundingSteps: 0.125 }) + .coordinateRoundingSteps, + ).toBe(0.125); + expect( + normalizeAppSettings({ coordinateRoundingSteps: 0.3 }) + .coordinateRoundingSteps, + ).toBe(0.25); }); test("bounds transition counts and preserves threshold invariants", () => { @@ -392,37 +406,7 @@ class SettingsFakeDatabase { default_field_preset: params[4], transition_metric_mode: params[5], count_display_mode: params[6], - guidance_enabled: params[7], - developer_mode_enabled: params[8], - show_cached_anchor_geometry: params[9], - show_comfortable_anchor_range: params[10], - show_perimeter_step_grid: params[11], - show_auxiliary_field_marks: params[12], - show_performer_labels: params[13], - show_performer_names: params[14], - show_prop_labels: params[15], - show_prop_names: params[16], - show_transition_markers: params[17], - show_all_transition_sets: params[18], - previous_transition_set_count: params[19], - next_transition_set_count: params[20], - distance_green_threshold_steps: params[21], - distance_yellow_threshold_steps: params[22], - motion_interpolation_enabled: params[23], - mock_live_position_enabled: params[24], - mock_live_position_x_steps: params[25], - mock_live_position_y_steps: params[26], - comfortable_anchor_range_meters: params[27], - }; - } else { - this.row = { - appearance_mode: params[1], - drill_features_enabled: params[2], - drill_terminology: params[3], - field_perspective: params[4], - default_field_preset: params[5], - transition_metric_mode: params[6], - count_display_mode: params[7], + coordinate_rounding_steps: params[7], guidance_enabled: params[8], developer_mode_enabled: params[9], show_cached_anchor_geometry: params[10], @@ -444,8 +428,40 @@ class SettingsFakeDatabase { mock_live_position_x_steps: params[26], mock_live_position_y_steps: params[27], comfortable_anchor_range_meters: params[28], - active_drill_id: params[29], - selected_drill_page_id: params[30], + }; + } else { + this.row = { + appearance_mode: params[1], + drill_features_enabled: params[2], + drill_terminology: params[3], + field_perspective: params[4], + default_field_preset: params[5], + transition_metric_mode: params[6], + count_display_mode: params[7], + coordinate_rounding_steps: params[8], + guidance_enabled: params[9], + developer_mode_enabled: params[10], + show_cached_anchor_geometry: params[11], + show_comfortable_anchor_range: params[12], + show_perimeter_step_grid: params[13], + show_auxiliary_field_marks: params[14], + show_performer_labels: params[15], + show_performer_names: params[16], + show_prop_labels: params[17], + show_prop_names: params[18], + show_transition_markers: params[19], + show_all_transition_sets: params[20], + previous_transition_set_count: params[21], + next_transition_set_count: params[22], + distance_green_threshold_steps: params[23], + distance_yellow_threshold_steps: params[24], + motion_interpolation_enabled: params[25], + mock_live_position_enabled: params[26], + mock_live_position_x_steps: params[27], + mock_live_position_y_steps: params[28], + comfortable_anchor_range_meters: params[29], + active_drill_id: params[30], + selected_drill_page_id: params[31], }; } return { lastInsertRowId: 1, changes: 1 }; @@ -463,6 +479,7 @@ function settingsRow(overrides: Record = {}) { default_field_preset: "football-nfhs", transition_metric_mode: "step-size", count_display_mode: "counts", + coordinate_rounding_steps: 0.25, guidance_enabled: 1, developer_mode_enabled: 0, show_cached_anchor_geometry: 0, diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts index da3c6b0a..9a5bf761 100644 --- a/packages/mobile/src/settings/types.ts +++ b/packages/mobile/src/settings/types.ts @@ -5,6 +5,11 @@ export type FieldPerspective = "director" | "performer"; export type AppearanceMode = "system" | "light" | "dark"; export type TransitionMetricMode = "step-size" | "crossing-counts"; export type CountDisplayMode = "counts" | "measures"; +export type CoordinateRoundingSteps = 0.125 | 0.25 | 0.5 | 1; + +export const COORDINATE_ROUNDING_PRESETS = Object.freeze([ + 0.125, 0.25, 0.5, 1, +] as const satisfies readonly CoordinateRoundingSteps[]); export const DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS = 20; export const MAX_COMFORTABLE_ANCHOR_RANGE_METERS = 200; @@ -22,6 +27,7 @@ export interface AppSettings { readonly defaultFieldPreset: FieldPresetId; readonly transitionMetricMode: TransitionMetricMode; readonly countDisplayMode: CountDisplayMode; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; readonly guidanceEnabled: boolean; readonly developerModeEnabled: boolean; readonly showCachedAnchorGeometry: boolean; @@ -57,6 +63,7 @@ export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ defaultFieldPreset: "football-nfhs", transitionMetricMode: "step-size", countDisplayMode: "counts", + coordinateRoundingSteps: 0.25, guidanceEnabled: true, developerModeEnabled: false, showCachedAnchorGeometry: false, @@ -91,6 +98,7 @@ export const APP_PREFERENCE_KEYS = Object.freeze([ "defaultFieldPreset", "transitionMetricMode", "countDisplayMode", + "coordinateRoundingSteps", "guidanceEnabled", "developerModeEnabled", "showCachedAnchorGeometry", @@ -165,6 +173,11 @@ export function normalizeAppSettings(value?: unknown): AppSettings { candidate.countDisplayMode === "measures" ? candidate.countDisplayMode : DEFAULT_APP_SETTINGS.countDisplayMode, + coordinateRoundingSteps: isCoordinateRoundingSteps( + candidate.coordinateRoundingSteps, + ) + ? candidate.coordinateRoundingSteps + : DEFAULT_APP_SETTINGS.coordinateRoundingSteps, guidanceEnabled: booleanOrDefault( candidate.guidanceEnabled, DEFAULT_APP_SETTINGS.guidanceEnabled, @@ -314,6 +327,15 @@ export function selectShowPerimeterStepGrid(value: AppSettings): boolean { return getEffectiveAppSettings(value).showPerimeterStepGrid; } +function isCoordinateRoundingSteps( + value: unknown, +): value is CoordinateRoundingSteps { + return ( + typeof value === "number" && + COORDINATE_ROUNDING_PRESETS.includes(value as CoordinateRoundingSteps) + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts index 59393e54..a8be6f38 100644 --- a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -15,7 +15,7 @@ describe("mobile app SQLite schema preparation", () => { const sql = executed.join("\n"); expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); - expect(MOBILE_SCHEMA_VERSION).toBe(8); + expect(MOBILE_SCHEMA_VERSION).toBe(9); expect(sql).toContain("PRAGMA journal_mode = WAL"); expect(sql).toContain("PRAGMA foreign_keys = OFF"); expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); @@ -43,6 +43,10 @@ describe("mobile app SQLite schema preparation", () => { expect(sql).toContain("default_field_preset TEXT NOT NULL"); expect(sql).toContain("show_perimeter_step_grid INTEGER NOT NULL"); expect(sql).toContain("count_display_mode TEXT NOT NULL DEFAULT 'counts'"); + expect(sql).toContain( + "coordinate_rounding_steps REAL NOT NULL DEFAULT 0.25", + ); + expect(sql).toContain("coordinate_rounding_steps IN (0.125, 0.25, 0.5, 1)"); expect(sql).toContain( "previous_transition_set_count INTEGER NOT NULL DEFAULT 1", ); diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts index 55ff6d0e..57c31b18 100644 --- a/packages/mobile/src/storage/mobileDatabase.ts +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -10,7 +10,7 @@ export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; * stable, a version mismatch intentionally rebuilds this disposable database * rather than carrying migration code for development-only layouts. */ -export const MOBILE_SCHEMA_VERSION = 8; +export const MOBILE_SCHEMA_VERSION = 9; export const DRILLS_TABLE = "drills"; export const DRILL_SETS_TABLE = "drill_sets"; @@ -160,6 +160,8 @@ async function createCurrentSchema(db: SQLiteDatabase): Promise { CHECK (transition_metric_mode IN ('step-size', 'crossing-counts')), count_display_mode TEXT NOT NULL DEFAULT 'counts' CHECK (count_display_mode IN ('counts', 'measures')), + coordinate_rounding_steps REAL NOT NULL DEFAULT 0.25 + CHECK (coordinate_rounding_steps IN (0.125, 0.25, 0.5, 1)), guidance_enabled INTEGER NOT NULL DEFAULT 1 CHECK (guidance_enabled IN (0, 1)), developer_mode_enabled INTEGER NOT NULL DEFAULT 0 From 39911cdf5040cd5119cdeecedc8b244fc9aa9f60 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 03:38:56 -0500 Subject: [PATCH 089/101] fix(field): Refine drill HUD spacing --- .../drill-pill/drill-set-metric-grid.tsx | 5 +- .../src/features/field/live-position-hud.tsx | 59 ++++++++++++------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx index bf855558..10fd3192 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx @@ -83,10 +83,7 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ } pointerEvents={onToggleCounts ? "auto" : "none"} onPress={onToggleCounts} - style={{ - width: columns.countWidth, - paddingLeft: columns.horizontalPadding, - }} + style={{ width: columns.countWidth }} > - + + + - + + + void; }) { @@ -163,8 +180,8 @@ function LivePositionHeader({ @@ -177,9 +194,9 @@ function LivePositionHeader({ coordinate={coordinate} color={theme.text} mutedColor={theme.textMuted} - fontSize={compact ? 15 : 18} - lineHeight={compact ? 18 : 22} - iconSize={compact ? 13 : 15} + fontSize={compact ? 13 : 18} + lineHeight={compact ? 16 : 22} + iconSize={compact ? 12 : 15} /> From c172383809f3f82997c60e18176ace584f8c4e5e Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 04:04:13 -0500 Subject: [PATCH 090/101] fix(mobile): Refine field HUD and drill actions --- .../drill/components/drill-list-item.tsx | 79 +++++++++++-------- .../components/drill-properties-dialog.tsx | 10 ++- .../features/field/coordinate-lines-view.tsx | 2 +- .../drill-pill/drill-set-metric-grid.tsx | 13 ++- .../src/features/field/live-position-hud.tsx | 30 +++++-- 5 files changed, 83 insertions(+), 51 deletions(-) diff --git a/apps/mobile/src/features/drill/components/drill-list-item.tsx b/apps/mobile/src/features/drill/components/drill-list-item.tsx index 7b697450..e6e2ce0d 100644 --- a/apps/mobile/src/features/drill/components/drill-list-item.tsx +++ b/apps/mobile/src/features/drill/components/drill-list-item.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Animated } from "react-native"; +import { Animated, Pressable as NativePressable, View } from "react-native"; import { CircleCheck, CirclePlus, @@ -10,7 +10,6 @@ import type { Drill, DrillTerms } from "@eight2five/mobile/drill"; import { Card } from "@eight2five/ui/components/card"; import { HStack } from "@eight2five/ui/components/hstack"; import { Icon } from "@eight2five/ui/components/icon"; -import { Pressable } from "@eight2five/ui/components/pressable"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { @@ -48,7 +47,9 @@ export const DrillListItem = React.memo(function DrillListItem({ const theme = useEight2FiveTheme(); const countLabel = formatDrillCount(pageCount, terms); const actionLabels = getDrillCardActionLabels(drill.name); - const DrillIcon = resolveDrillIcon(drill.metadata?.lucideIcon); + const DrillIcon = drill.metadata?.lucideIcon + ? resolveDrillIcon(drill.metadata.lucideIcon) + : undefined; return ( - - - + {DrillIcon ? ( + + + + ) : null} - ({ + width: 44, + height: 44, alignItems: "center", justifyContent: "center", - }} + opacity: busy ? 0.45 : pressed ? 0.6 : 1, + })} > - + @@ -140,21 +145,25 @@ function DrillActionButton({ readonly iconColor: string; }) { return ( - ({ + width: 40, + height: 40, alignItems: "center", justifyContent: "center", - }} + opacity: disabled ? 0.45 : pressed ? 0.6 : 1, + })} > - - + + + + ); } @@ -189,27 +198,33 @@ function AnimatedSelectionIcon({ }); return ( - <> + - + - + - + ); } diff --git a/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx index c107ebaa..87dbc021 100644 --- a/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx +++ b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx @@ -59,7 +59,9 @@ export function DrillPropertiesDialog({ const theme = useEight2FiveTheme(); if (!drill) return null; const metadata = document?.metadata ?? drill.metadata; - const DrillIcon = resolveDrillIcon(drill.metadata?.lucideIcon); + const DrillIcon = drill.metadata?.lucideIcon + ? resolveDrillIcon(drill.metadata.lucideIcon) + : undefined; const confirmDelete = () => { Alert.alert( @@ -99,10 +101,12 @@ export function DrillPropertiesDialog({ ) : null} - + {DrillIcon ? ( + + ) : null} + - + - + void; }) { @@ -187,6 +200,7 @@ function LivePositionHeader({ > @@ -205,9 +219,11 @@ function LivePositionHeader({ function BluetoothStatusButton({ state, + size = 44, onPress, }: { readonly state: FieldConnectionState; + readonly size?: number; readonly onPress: () => void; }) { const theme = useEight2FiveTheme(); @@ -249,8 +265,8 @@ function BluetoothStatusButton({ onPress={onPress} hitSlop={8} style={{ - width: 44, - height: 44, + width: size, + height: size, alignItems: "center", justifyContent: "center", }} From c99ff81bb085a45a1069a311ca512c50d8e118da Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 04:04:22 -0500 Subject: [PATCH 091/101] fix(field): Correct sideline labels and hash cadence --- .../src/field/__tests__/field-paths.test.ts | 19 +++++++++++++------ .../src/field/render/create-field-paths.ts | 8 +++----- .../src/field/render/field-static-layer.tsx | 17 +++++++++++------ 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/mobile/src/field/__tests__/field-paths.test.ts b/packages/mobile/src/field/__tests__/field-paths.test.ts index 1e1889ef..544e253d 100644 --- a/packages/mobile/src/field/__tests__/field-paths.test.ts +++ b/packages/mobile/src/field/__tests__/field-paths.test.ts @@ -188,13 +188,13 @@ describe("aggregate field paths", () => { }, ); - test("renders perpendicular inbounds hashes without treating guides as ticks", () => { + test("renders one perpendicular inbounds hash on each full yard line", () => { const paths = createFieldPaths(field); const hashSegments = parseSubpaths(paths.hashMarksPath); expect(subpathCount(paths.yardLinesPath)).toBe(19); expect(paths.counts.yardLines.lineCount).toBe(19); - expect(hashSegments).toHaveLength(198); + expect(hashSegments).toHaveLength(38); expect( hashSegments.every( ({ x1, y1, x2, y2 }) => @@ -208,13 +208,20 @@ describe("aggregate field paths", () => { ).toBe(true); expect(paths.counts.hashMarks).toMatchObject({ rowCount: 2, - ticksPerRow: 99, - tickCount: 198, - spacingMeters: - field.fieldDefinition.markings.inboundsHashMarks.spacingMeters, + ticksPerRow: 19, + tickCount: 38, + spacingMeters: field.dimensions.fiveYardLineSpacingMeters, tickLengthMeters: field.fieldDefinition.markings.inboundsHashMarks.lengthMeters, }); + const yardLineCoordinates = new Set( + field.yardLines.map((line) => Number(line.coordinateMeters.toFixed(6))), + ); + expect( + hashSegments.every(({ x1, x2 }) => + yardLineCoordinates.has(Number(((x1 + x2) / 2).toFixed(6))), + ), + ).toBe(true); expect(paths.hashGuideLinesPath).toBe(""); expect(subpathCount(paths.hashGuideLinesPath)).toBe(0); expect(paths.counts.hashGuideLines.lineCount).toBe(0); diff --git a/packages/mobile/src/field/render/create-field-paths.ts b/packages/mobile/src/field/render/create-field-paths.ts index 189fbc55..ba63d975 100644 --- a/packages/mobile/src/field/render/create-field-paths.ts +++ b/packages/mobile/src/field/render/create-field-paths.ts @@ -205,10 +205,8 @@ export function createFieldPaths( ] as const; const inboundsMarkings = template.fieldDefinition.markings.inboundsHashMarks; const hashMarks: string[] = []; - const inboundsXCoordinates = spacedInteriorCoordinates( - fieldExtent.minXMeters, - fieldExtent.maxXMeters, - inboundsMarkings.spacingMeters, + const inboundsXCoordinates = template.yardLines.map( + (line) => line.coordinateMeters, ); for (const yMeters of hashYCoordinates) { for (const xMeters of inboundsXCoordinates) { @@ -283,7 +281,7 @@ export function createFieldPaths( }), yardLines: Object.freeze({ lineCount: template.yardLines.length }), hashMarks: Object.freeze({ - spacingMeters: inboundsMarkings.spacingMeters, + spacingMeters: template.dimensions.fiveYardLineSpacingMeters, tickLengthMeters: inboundsMarkings.lengthMeters, rowCount: 2, ticksPerRow: inboundsXCoordinates.length, diff --git a/packages/mobile/src/field/render/field-static-layer.tsx b/packages/mobile/src/field/render/field-static-layer.tsx index cb800faf..e23c4bac 100644 --- a/packages/mobile/src/field/render/field-static-layer.tsx +++ b/packages/mobile/src/field/render/field-static-layer.tsx @@ -204,13 +204,18 @@ function SidelineLabel({ const width = font.measureText(text).width; const transform = useDerivedValue(() => { const scale = metersPerPixel.value; - return perspective === "performer" - ? [{ scaleX: -scale }, { scaleY: scale }] - : [{ scaleX: scale }, { scaleY: -scale }]; + const uprightScaleX = perspective === "performer" ? -scale : scale; + const uprightScaleY = perspective === "performer" ? scale : -scale; + const orientation = atTop ? 1 : -1; + return [ + { scaleX: uprightScaleX * orientation }, + { scaleY: uprightScaleY * orientation }, + ]; }); - const baselineOffset = atTop - ? SIDELINE_LABEL_FONT_SIZE_PX + SIDELINE_LABEL_INSET_PX - : -SIDELINE_LABEL_INSET_PX; + // Keep the label outside the field with the bottom of the lettering facing + // its sideline. The lower on-screen label is rotated 180 degrees, so the + // same local baseline offset works for both sides. + const baselineOffset = -SIDELINE_LABEL_INSET_PX; return ( From 0ac83cc132e00079ba2450170e01ee3ebababfc7 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 04:34:55 -0500 Subject: [PATCH 092/101] fix(field): Scale position markers with the grid --- .../src/features/settings/settings-screen.tsx | 2 +- .../src/drill/__tests__/render-scene.test.ts | 16 +++-- .../src/field/render/field-drill-layer.tsx | 68 +++++-------------- .../src/field/render/field-guidance-layer.tsx | 13 +++- .../src/field/render/field-position-layer.tsx | 12 ++-- .../src/field/render/field-render-tokens.ts | 21 +++--- .../mobile/src/field/render/field-scene.tsx | 6 +- 7 files changed, 54 insertions(+), 84 deletions(-) diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx index c543138e..b5b94128 100644 --- a/apps/mobile/src/features/settings/settings-screen.tsx +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -382,7 +382,7 @@ export function SettingsScreen() { void update({ guidanceEnabled })} disabled={disabled} diff --git a/packages/mobile/src/drill/__tests__/render-scene.test.ts b/packages/mobile/src/drill/__tests__/render-scene.test.ts index 2be4b6cc..f8a7fdc3 100644 --- a/packages/mobile/src/drill/__tests__/render-scene.test.ts +++ b/packages/mobile/src/drill/__tests__/render-scene.test.ts @@ -2,6 +2,8 @@ import { DRILL_MARKER_COLORS, DRILL_MARKER_SIZE_METERS, DRILL_MARKER_SIZE_STEPS, + LIVE_POSITION_MARKER_DIAMETER_METERS, + LIVE_POSITION_MARKER_SIZE_STEPS, } from "../../field/render/field-render-tokens"; import { buildDrillRenderScene, @@ -346,13 +348,15 @@ describe("selected-set drill render scene", () => { ).toEqual({ widthMeters: 2, lengthMeters: 1 }); expect(DEFAULT_PERFORMER_DIAMETER_METERS).toBe(0.5715); expect(DRILL_MARKER_SIZE_STEPS).toEqual({ - currentDiameter: 1.5, - transitionDiameter: 0.75, - midpointDiameter: 0.375, + currentDiameter: 2, + transitionDiameter: 1, + midpointDiameter: 0.5, }); - expect(DRILL_MARKER_SIZE_METERS.currentDiameter).toBeCloseTo(0.85725); - expect(DRILL_MARKER_SIZE_METERS.transitionDiameter).toBeCloseTo(0.428625); - expect(DRILL_MARKER_SIZE_METERS.midpointDiameter).toBeCloseTo(0.2143125); + expect(DRILL_MARKER_SIZE_METERS.currentDiameter).toBeCloseTo(1.143); + expect(DRILL_MARKER_SIZE_METERS.transitionDiameter).toBeCloseTo(0.5715); + expect(DRILL_MARKER_SIZE_METERS.midpointDiameter).toBeCloseTo(0.28575); + expect(LIVE_POSITION_MARKER_SIZE_STEPS).toBe(1.5); + expect(LIVE_POSITION_MARKER_DIAMETER_METERS).toBeCloseTo(0.85725); expect(DRILL_MARKER_COLORS).toEqual({ yellow: COLOR_PRESETS.yellow, red: COLOR_PRESETS.red, diff --git a/packages/mobile/src/field/render/field-drill-layer.tsx b/packages/mobile/src/field/render/field-drill-layer.tsx index 983d766b..cb3a74e2 100644 --- a/packages/mobile/src/field/render/field-drill-layer.tsx +++ b/packages/mobile/src/field/render/field-drill-layer.tsx @@ -32,8 +32,9 @@ import { import type { FieldRenderPalette } from "./field-render-tokens"; import { DRILL_MARKER_COLORS, - DRILL_MARKER_SIZE_PIXELS, + DRILL_MARKER_SIZE_METERS, } from "./field-render-tokens"; +import { STANDARD_STEP_METERS } from "../units"; const EMPTY_ENTITIES: readonly DrillRenderEntity[] = Object.freeze([]); const EMPTY_DOTS = Object.freeze([]) as readonly { @@ -45,10 +46,10 @@ const EMPTY_TRANSITIONS = Object.freeze( ) as readonly PhysicalImmediateTransition[]; const LABEL_FONT_SIZE_PX = 12; const LABEL_LINE_HEIGHT_PX = 14; -const MARKER_STROKE_PX = 2; +const MARKER_STROKE_METERS = STANDARD_STEP_METERS * 0.12; const CONNECTOR_STROKE_PX = 1.25; -const DASH_LENGTH_PX = 2.4; -const DASH_GAP_PX = 1.6; +const DASH_LENGTH_METERS = STANDARD_STEP_METERS * 0.25; +const DASH_GAP_METERS = STANDARD_STEP_METERS * 0.15; const EXTRA_TRANSITION_OPACITY = 0.68; export interface FieldDrillLayerProps { @@ -117,7 +118,6 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ key={`previous-dot-${dot.setId}`} point={dot.point} color={DRILL_MARKER_COLORS.red} - metersPerPixel={metersPerPixel} /> ))} {nextDots.map((dot) => ( @@ -125,7 +125,6 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ key={`next-dot-${dot.setId}`} point={dot.point} color={DRILL_MARKER_COLORS.green} - metersPerPixel={metersPerPixel} /> ))} {scene?.previous ? ( @@ -142,12 +141,7 @@ export const FieldDrillLayer = React.memo(function FieldDrillLayer({ metersPerPixel={metersPerPixel} /> ) : null} - {targetPoint ? ( - - ) : null} + {targetPoint ? : null} ); }); @@ -297,16 +291,11 @@ function EntityLabel({ function ExtraDot({ point, color, - metersPerPixel, }: { readonly point: PhysicalFieldPoint; readonly color: string; - readonly metersPerPixel: SharedValue; }) { - const radius = useDerivedValue( - () => - (metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.midpointDiameter) / 2, - ); + const radius = DRILL_MARKER_SIZE_METERS.midpointDiameter / 2; return ( - (metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.transitionDiameter) / 2, - ); - const midpointRadius = useDerivedValue( - () => - (metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.midpointDiameter) / 2, - ); - const centerRadius = useDerivedValue( - () => - metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.transitionDiameter * 0.18, - ); - const markerStrokeWidth = useDerivedValue( - () => metersPerPixel.value * MARKER_STROKE_PX, - ); + const markerRadius = DRILL_MARKER_SIZE_METERS.transitionDiameter / 2; + const midpointRadius = DRILL_MARKER_SIZE_METERS.midpointDiameter / 2; + const centerRadius = DRILL_MARKER_SIZE_METERS.transitionDiameter * 0.18; const connectorStrokeWidth = useDerivedValue( () => metersPerPixel.value * CONNECTOR_STROKE_PX, ); - const dashIntervals = useDerivedValue(() => [ - metersPerPixel.value * DASH_LENGTH_PX, - metersPerPixel.value * DASH_GAP_PX, - ]); + const dashIntervals = [DASH_LENGTH_METERS, DASH_GAP_METERS]; const connectorColor = kind === "previous" ? DRILL_MARKER_COLORS.red : DRILL_MARKER_COLORS.green; @@ -407,7 +381,7 @@ function ImmediateTransitionLayer({ r={markerRadius} color={connectorColor} style="stroke" - strokeWidth={markerStrokeWidth} + strokeWidth={MARKER_STROKE_METERS} > @@ -426,7 +400,7 @@ function ImmediateTransitionLayer({ r={markerRadius} color={connectorColor} style="stroke" - strokeWidth={markerStrokeWidth} + strokeWidth={MARKER_STROKE_METERS} /> )} @@ -448,21 +422,11 @@ function ImmediateTransitionLayer({ function CurrentTargetMarker({ point, - metersPerPixel, }: { readonly point: PhysicalFieldPoint | FieldPoint; - readonly metersPerPixel: SharedValue; }) { - const radius = useDerivedValue( - () => (metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.currentDiameter) / 2, - ); - const centerRadius = useDerivedValue( - () => - metersPerPixel.value * DRILL_MARKER_SIZE_PIXELS.currentDiameter * 0.14, - ); - const strokeWidth = useDerivedValue( - () => metersPerPixel.value * MARKER_STROKE_PX, - ); + const radius = DRILL_MARKER_SIZE_METERS.currentDiameter / 2; + const centerRadius = DRILL_MARKER_SIZE_METERS.currentDiameter * 0.14; return ( <> @@ -473,7 +437,7 @@ function CurrentTargetMarker({ r={radius} color={DRILL_MARKER_COLORS.yellow} style="stroke" - strokeWidth={strokeWidth} + strokeWidth={MARKER_STROKE_METERS} /> livePosition.value === null ? 0 : 0.82, ); - const strokeWidth = useDerivedValue(() => metersPerPixel.value * 1.25); + const strokeWidth = useDerivedValue(() => metersPerPixel.value * 2.4); + const dashIntervals = useDerivedValue(() => [ + metersPerPixel.value * 8, + metersPerPixel.value * 5, + ]); return ( + strokeCap="round" + > + + ); } diff --git a/packages/mobile/src/field/render/field-position-layer.tsx b/packages/mobile/src/field/render/field-position-layer.tsx index fbb4d6e8..5c8696ee 100644 --- a/packages/mobile/src/field/render/field-position-layer.tsx +++ b/packages/mobile/src/field/render/field-position-layer.tsx @@ -2,21 +2,23 @@ import { Circle } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import type { FieldPoint } from "../types"; -import type { FieldRenderPalette } from "./field-render-tokens"; +import { + LIVE_POSITION_MARKER_DIAMETER_METERS, + type FieldRenderPalette, +} from "./field-render-tokens"; +import { STANDARD_STEP_METERS } from "../units"; export function FieldPositionLayer({ livePosition, - metersPerPixel, palette, }: { readonly livePosition: SharedValue; - readonly metersPerPixel: SharedValue; readonly palette: FieldRenderPalette; }) { const cx = useDerivedValue(() => livePosition.value?.xMeters ?? -1_000_000); const cy = useDerivedValue(() => livePosition.value?.yMeters ?? -1_000_000); - const outerRadius = useDerivedValue(() => metersPerPixel.value * 9); - const innerRadius = useDerivedValue(() => metersPerPixel.value * 7); + const outerRadius = LIVE_POSITION_MARKER_DIAMETER_METERS / 2; + const innerRadius = outerRadius - STANDARD_STEP_METERS * 0.1; const liveOpacity = useDerivedValue(() => livePosition.value === null ? 0 : 1, ); diff --git a/packages/mobile/src/field/render/field-render-tokens.ts b/packages/mobile/src/field/render/field-render-tokens.ts index 468709e4..db9c8a3a 100644 --- a/packages/mobile/src/field/render/field-render-tokens.ts +++ b/packages/mobile/src/field/render/field-render-tokens.ts @@ -31,21 +31,14 @@ export const DRILL_MARKER_COLORS = Object.freeze({ green: COLOR_PRESETS.green, }); -/** Marker diameters are physical sizes expressed in standard steps. */ +/** Marker diameters are physical sizes expressed in standard 8:5 steps. */ export const DRILL_MARKER_SIZE_STEPS = Object.freeze({ - currentDiameter: 1.5, - transitionDiameter: 0.75, - midpointDiameter: 0.375, + currentDiameter: 2, + transitionDiameter: 1, + midpointDiameter: 0.5, }); -/** Fixed screen-space marker diameters used by the interactive field HUD. */ -export const DRILL_MARKER_SIZE_PIXELS = Object.freeze({ - currentDiameter: 16, - transitionDiameter: 8, - midpointDiameter: 4, -}); - -/** Legacy physical equivalents retained for non-render calculations/tests. */ +/** World-space diameters used by the renderer so markers stay locked to the grid. */ export const DRILL_MARKER_SIZE_METERS = Object.freeze({ currentDiameter: DRILL_MARKER_SIZE_STEPS.currentDiameter * STANDARD_STEP_METERS, @@ -55,6 +48,10 @@ export const DRILL_MARKER_SIZE_METERS = Object.freeze({ DRILL_MARKER_SIZE_STEPS.midpointDiameter * STANDARD_STEP_METERS, }); +export const LIVE_POSITION_MARKER_SIZE_STEPS = 1.5; +export const LIVE_POSITION_MARKER_DIAMETER_METERS = + LIVE_POSITION_MARKER_SIZE_STEPS * STANDARD_STEP_METERS; + export const DEFAULT_FIELD_RENDER_PALETTE: FieldRenderPalette = Object.freeze({ canvasBackground: "#E7EAF0", stepGrid: "rgba(76, 93, 120, 0.22)", diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx index 0fe15ccf..b77861da 100644 --- a/packages/mobile/src/field/render/field-scene.tsx +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -100,11 +100,7 @@ export function FieldScene({ color={palette.guidance} /> ) : null} - + ); } From 315b7129c033e9aeec846fc665208bcca92db6c1 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 04:35:12 -0500 Subject: [PATCH 093/101] fix(mobile): Refine drill card and HUD sizing --- .../drill/components/drill-list-item.tsx | 170 ++++++++++-------- .../drill-pill/drill-set-metric-grid.tsx | 28 ++- .../src/features/field/live-position-hud.tsx | 6 +- 3 files changed, 123 insertions(+), 81 deletions(-) diff --git a/apps/mobile/src/features/drill/components/drill-list-item.tsx b/apps/mobile/src/features/drill/components/drill-list-item.tsx index e6e2ce0d..daa2cbc0 100644 --- a/apps/mobile/src/features/drill/components/drill-list-item.tsx +++ b/apps/mobile/src/features/drill/components/drill-list-item.tsx @@ -1,5 +1,10 @@ import React from "react"; -import { Animated, Pressable as NativePressable, View } from "react-native"; +import { + Animated, + Pressable as NativePressable, + View, + type GestureResponderEvent, +} from "react-native"; import { CircleCheck, CirclePlus, @@ -61,72 +66,90 @@ export const DrillListItem = React.memo(function DrillListItem({ backgroundColor: theme.surfaceRaised, }} > - { + if (!active) onToggleActive(); + }} + style={({ pressed }) => ({ + opacity: busy ? 0.55 : pressed ? 0.8 : 1, + })} > - {DrillIcon ? ( - - + + {DrillIcon ? ( + + + + ) : null} + + + {drill.name} + + + {countLabel} + - ) : null} - - - {drill.name} - - - {countLabel} - - - - - - ({ - width: 44, - height: 44, - alignItems: "center", - justifyContent: "center", - opacity: busy ? 0.45 : pressed ? 0.6 : 1, - })} - > - - + + + + { + event.stopPropagation(); + onToggleActive(); + }} + disabled={busy} + accessibilityRole="button" + accessibilityLabel={ + active ? actionLabels.deactivate : actionLabels.activate + } + accessibilityState={{ disabled: busy, selected: active }} + hitSlop={4} + style={({ pressed }) => ({ + width: 46, + height: 46, + alignItems: "center", + justifyContent: "center", + opacity: busy ? 0.45 : pressed ? 0.6 : 1, + })} + > + + + - + ); }); @@ -146,22 +169,25 @@ function DrillActionButton({ }) { return ( { + event.stopPropagation(); + onPress(); + }} disabled={disabled} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled }} hitSlop={6} style={({ pressed }) => ({ - width: 40, - height: 40, + width: 42, + height: 42, alignItems: "center", justifyContent: "center", opacity: disabled ? 0.45 : pressed ? 0.6 : 1, })} > - + ); @@ -201,8 +227,8 @@ function AnimatedSelectionIcon({ - + - + ); diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx index 57615242..23cff9d5 100644 --- a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx +++ b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx @@ -60,7 +60,7 @@ export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ return ( - + From 8986e08dad5bb7476fa6f9a1b9679b68b75a1faa Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 04:35:22 -0500 Subject: [PATCH 094/101] feat(pans): Add local anchor display names --- .../settings/__tests__/anchor-display.test.ts | 26 ++++++++ .../src/features/settings/anchor-display.ts | 11 ++++ .../settings/anchor-editor-screen.tsx | 65 ++++++++++++++++++- .../features/settings/anchor-list-screen.tsx | 5 +- .../settings/network-detail-screen.tsx | 13 ++-- .../settings/use-anchor-editor-controller.ts | 28 ++++++++ .../pans/__tests__/mobile-pans-store.test.ts | 26 ++++++++ apps/mobile/src/pans/mobile-pans-store.ts | 28 ++++++++ packages/mobile/src/pans-manager/types.ts | 4 +- 9 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/features/settings/__tests__/anchor-display.test.ts create mode 100644 apps/mobile/src/features/settings/anchor-display.ts diff --git a/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts new file mode 100644 index 00000000..90e2e005 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts @@ -0,0 +1,26 @@ +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +import { getDeveloperAnchorDisplayName } from "../anchor-display"; + +const anchor = (overrides: Partial = {}): ManagedDevice => ({ + id: "anchor-id", + transportDeviceId: "transport-id", + createdAt: 1, + updatedAt: 1, + ...overrides, +}); + +describe("developer anchor display names", () => { + test("prefers the local name and falls back to hardware identifiers", () => { + expect( + getDeveloperAnchorDisplayName( + anchor({ nickname: " Front 50 ", nodeIdHex: "A001", label: "HW" }), + ), + ).toBe("Front 50"); + expect(getDeveloperAnchorDisplayName(anchor({ nodeIdHex: "A001" }))).toBe( + "A001", + ); + expect(getDeveloperAnchorDisplayName(anchor({ label: "HW" }))).toBe("HW"); + expect(getDeveloperAnchorDisplayName(anchor())).toBe("anchor-id"); + }); +}); diff --git a/apps/mobile/src/features/settings/anchor-display.ts b/apps/mobile/src/features/settings/anchor-display.ts new file mode 100644 index 00000000..3778397b --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-display.ts @@ -0,0 +1,11 @@ +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +export function getDeveloperAnchorDisplayName(anchor: ManagedDevice): string { + return ( + anchor.nickname?.trim() || + anchor.nodeIdHex?.trim() || + anchor.lastKnownConfig?.label?.trim() || + anchor.label?.trim() || + anchor.id + ); +} diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx index 80b3e308..159f9617 100644 --- a/apps/mobile/src/features/settings/anchor-editor-screen.tsx +++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx @@ -6,8 +6,16 @@ import { ButtonText, } from "@eight2five/ui/components/button"; import { Card } from "@eight2five/ui/components/card"; +import { + FormControl, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; import { HStack } from "@eight2five/ui/components/hstack"; import { Icon } from "@eight2five/ui/components/icon"; +import { Input, InputField } from "@eight2five/ui/components/input"; import { Text } from "@eight2five/ui/components/text"; import { VStack } from "@eight2five/ui/components/vstack"; import { @@ -18,6 +26,7 @@ import { import { MarchingCoordinateForm } from "../drill/components/marching-coordinate-form"; import { formatAnchorCanonicalPreview } from "./anchor-editor-form"; +import { getDeveloperAnchorDisplayName } from "./anchor-display"; import { confirmAnchorPositionWrite } from "./anchor-write-confirmation"; import { AnchorNumberInput, @@ -72,13 +81,63 @@ export function AnchorEditorScreen({ + + + + Local display name + + + + + + + Stored only on this device and never written to the anchor. + + + + + diff --git a/apps/mobile/src/features/settings/anchor-list-screen.tsx b/apps/mobile/src/features/settings/anchor-list-screen.tsx index 52650705..403b5924 100644 --- a/apps/mobile/src/features/settings/anchor-list-screen.tsx +++ b/apps/mobile/src/features/settings/anchor-list-screen.tsx @@ -13,6 +13,7 @@ import { VStack } from "@eight2five/ui/components/vstack"; import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; +import { getDeveloperAnchorDisplayName } from "./anchor-display"; import { useAnchorListController } from "./use-anchor-list-controller"; import { SettingsMessage, @@ -88,7 +89,7 @@ export function AnchorListScreen() { key={anchor.id} testID={`edit-anchor-${anchor.id}`} accessibilityRole="button" - accessibilityLabel={`Edit anchor ${anchor.nodeIdHex ?? anchor.label ?? anchor.id}`} + accessibilityLabel={`Edit anchor ${getDeveloperAnchorDisplayName(anchor)}`} onPress={() => router.push({ pathname: "/(tabs)/settings/anchor/[anchorId]", @@ -103,7 +104,7 @@ export function AnchorListScreen() { - {anchor.nodeIdHex ?? anchor.label ?? anchor.id} + {getDeveloperAnchorDisplayName(anchor)} Initiator:{" "} diff --git a/apps/mobile/src/features/settings/network-detail-screen.tsx b/apps/mobile/src/features/settings/network-detail-screen.tsx index 83773ae5..b1082f59 100644 --- a/apps/mobile/src/features/settings/network-detail-screen.tsx +++ b/apps/mobile/src/features/settings/network-detail-screen.tsx @@ -37,6 +37,7 @@ import { type NetworkDraft, } from "./network-form"; import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; +import { getDeveloperAnchorDisplayName } from "./anchor-display"; import { NetworkProfileForm } from "./network-profile-form"; import { anchorInitiatorLabel, @@ -408,7 +409,7 @@ function CachedAnchorRow({ className="flex-1" testID={`edit-network-anchor-${anchor.id}`} accessibilityRole="button" - accessibilityLabel={`Edit anchor ${anchor.nodeIdHex ?? anchor.label ?? anchor.id}`} + accessibilityLabel={`Edit anchor ${getDeveloperAnchorDisplayName(anchor)}`} disabled={busy} onPress={onEdit} > @@ -416,7 +417,7 @@ function CachedAnchorRow({ - {anchor.nodeIdHex ?? anchor.label ?? anchor.id} + {getDeveloperAnchorDisplayName(anchor)} Initiator: {initiator} @@ -456,11 +457,9 @@ function DiscoveryAnchorRow({ }) { const theme = useEight2FiveTheme(); const cached = row.cachedAnchor; - const title = - cached?.nodeIdHex ?? - cached?.label ?? - row.discovery.name ?? - row.discovery.transportDeviceId; + const title = cached + ? getDeveloperAnchorDisplayName(cached) + : (row.discovery.name ?? row.discovery.transportDeviceId); const action = cached ? "Assign to this network" : "Add as anchor"; return ( createAnchorEditorDrafts().standard, ); + const [localName, setLocalName] = React.useState(""); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); + const [savingLocalName, setSavingLocalName] = React.useState(false); const [saved, setSaved] = React.useState(false); const [error, setError] = React.useState(); @@ -76,6 +78,7 @@ export function useAnchorEditorController(anchorId: string) { ); const drafts = createAnchorEditorDrafts(position, nextFieldPreset); setAnchor(next); + setLocalName(next.nickname ?? ""); setFieldPreset(nextFieldPreset); setMarchingDraft(drafts.marching); setStandardDraft(drafts.standard); @@ -169,6 +172,26 @@ export function useAnchorEditorController(anchorId: string) { ); }; + const saveLocalName = async () => { + if (!anchor || savingLocalName || !settings.settings.developerModeEnabled) { + return; + } + setSavingLocalName(true); + setError(undefined); + try { + const savedAnchor = await pansStore.setAnchorLocalName( + anchor.id, + localName, + ); + setAnchor(savedAnchor); + setLocalName(savedAnchor.nickname ?? ""); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setSavingLocalName(false); + } + }; + const save = async (position: AnchorFieldPosition) => { if (saving || !settings.settings.developerModeEnabled) return; setSaving(true); @@ -196,11 +219,16 @@ export function useAnchorEditorController(anchorId: string) { marchingDraft, standardDraft, validation, + localName, + localNameDirty: localName.trim() !== (anchor?.nickname ?? ""), loading, saving, + savingLocalName, saved, error, setMode, + setLocalName, + saveLocalName, setMarchingDraft: (draft: MarchingAnchorDraft) => { setSaved(false); setMarchingDraft(draft); diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts index 6aedfe1d..8ada8787 100644 --- a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts +++ b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts @@ -438,6 +438,32 @@ describe("MobilePansStore", () => { await store.dispose(); }); + test("stores a developer-only local anchor name and refreshes the cache", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + await harness.repository.saveDevice(managedAnchor("named-anchor")); + await store.refreshCachedAnchors(); + + const saved = await store.setAnchorLocalName( + "named-anchor", + " Front 50 ", + ); + + expect(saved.nickname).toBe("Front 50"); + expect((await harness.repository.getDevice("named-anchor"))?.nickname).toBe( + "Front 50", + ); + expect(store.getSnapshot().knownAnchors).toContainEqual( + expect.objectContaining({ id: "named-anchor", nickname: "Front 50" }), + ); + expect(harness.configurationApply).not.toHaveBeenCalled(); + await store.dispose(); + }); + test("sets a reachable initiator and reports unreachable prior initiators", async () => { const harness = await createHarness(); const store = new MobilePansStore({ diff --git a/apps/mobile/src/pans/mobile-pans-store.ts b/apps/mobile/src/pans/mobile-pans-store.ts index 653aa5ea..977cc664 100644 --- a/apps/mobile/src/pans/mobile-pans-store.ts +++ b/apps/mobile/src/pans/mobile-pans-store.ts @@ -701,6 +701,34 @@ export class MobilePansStore { return knownAnchors; } + async setAnchorLocalName( + anchorId: string, + localName: string, + ): Promise { + if (!this.developerModeEnabled) { + throw new ManagerError( + "INVALID_CONFIGURATION", + "Enable Developer Mode before naming cached anchors.", + ); + } + const runtime = this.requireRuntime(); + const anchor = await runtime.repository.getDevice(anchorId); + if ( + !anchor || + (anchor.role !== "anchor" && anchor.lastKnownConfig?.role !== "anchor") + ) { + throw new Error("The selected cached anchor does not exist."); + } + const nickname = localName.trim() || undefined; + const saved = await runtime.repository.saveDevice({ + ...anchor, + nickname, + updatedAt: Date.now(), + }); + await this.refreshCachedAnchors(); + return saved; + } + async writeAnchorPosition( anchorId: string, position: AnchorFieldPosition, diff --git a/packages/mobile/src/pans-manager/types.ts b/packages/mobile/src/pans-manager/types.ts index 4417b414..2ad351f7 100644 --- a/packages/mobile/src/pans-manager/types.ts +++ b/packages/mobile/src/pans-manager/types.ts @@ -125,7 +125,7 @@ export type ManagedDeviceConfig = ManagedTagConfig | ManagedAnchorConfig; /** App-only fields which may be independently saved without a BLE session. */ export interface LocalDeviceChanges { - /** @deprecated PANS device nicknames are retained for database compatibility only. */ + /** App-only display name. Never written to PANS hardware. */ nickname?: string | undefined; /** @deprecated PANS device notes are retained for database compatibility only. */ notes?: string | undefined; @@ -163,7 +163,7 @@ export interface ManagedDevice { transportDeviceId: string; macAddress?: string; nodeIdHex?: string; - /** @deprecated Retained only for database/import compatibility. */ + /** App-only display name. Never written to PANS hardware. */ nickname?: string; /** Legacy hardware label cache. Prefer lastKnownConfig.label. */ label?: string; From a6a8f43866246323e877970a1fa8236948a0ac73 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 04:59:15 -0500 Subject: [PATCH 095/101] fix(drill): Respect imported entity visibility --- .../src/drill/__tests__/render-scene.test.ts | 27 +++++++++++++++++++ packages/mobile/src/drill/render-scene.ts | 4 +++ 2 files changed, 31 insertions(+) diff --git a/packages/mobile/src/drill/__tests__/render-scene.test.ts b/packages/mobile/src/drill/__tests__/render-scene.test.ts index f8a7fdc3..5fff205b 100644 --- a/packages/mobile/src/drill/__tests__/render-scene.test.ts +++ b/packages/mobile/src/drill/__tests__/render-scene.test.ts @@ -223,6 +223,33 @@ describe("selected-set drill render scene", () => { expect(Object.isFrozen(scene.entities)).toBe(true); }); + test("temporarily treats JSON label visibility as ordinary entity visibility", () => { + const hiddenDocument: DrillDocument = { + ...DOCUMENT, + entities: DOCUMENT.entities.map((entity) => + entity.id === 2 || entity.id === 3 + ? { + ...entity, + appearance: { + ...entity.appearance, + labelVisible: false, + }, + } + : entity, + ), + }; + const scene = buildDrillRenderScene({ + document: hiddenDocument, + field: "football-nfhs", + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + + expect(scene.entities).toEqual([]); + expect(scene.current).toEqual(physicalPoint({ xSteps: 8, ySteps: 8 })); + }); + test("keeps labels and names independent and marker master only suppresses transition graphics", () => { const scene = buildDrillRenderScene({ document: DOCUMENT, diff --git a/packages/mobile/src/drill/render-scene.ts b/packages/mobile/src/drill/render-scene.ts index fb7ac18a..b1207520 100644 --- a/packages/mobile/src/drill/render-scene.ts +++ b/packages/mobile/src/drill/render-scene.ts @@ -198,6 +198,10 @@ export function buildDrillRenderScene( ) { continue; } + // TODO: Fix this once the drill schema has an explicit entity visibility + // property. For now, imported label visibility controls whether the + // performer/prop itself is rendered. + if (!resolved.appearance.labelVisible) continue; const position = positionsByEntityId.get(resolved.id); if (!position) continue; entities.push( From c88a4c12a4ce7ead7106a5986272486854fe6061 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 05:07:45 -0500 Subject: [PATCH 096/101] fix(mobile): Tighten landscape controls and add sparkle icon --- .../features/drill/__tests__/drill-icons.test.ts | 1 + apps/mobile/src/features/drill/drill-icons.ts | 2 ++ .../field/__tests__/field-overlay-layout.test.ts | 14 ++++++++++++++ .../src/features/field/field-overlay-layout.tsx | 9 ++++++++- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts b/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts index 14e0e837..c8276fcf 100644 --- a/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts +++ b/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts @@ -10,6 +10,7 @@ describe("drill card icon registry", () => { expect(resolveDrillIcon("music-2")).toBe(DRILL_ICON_REGISTRY["music-2"]); expect(resolveDrillIcon("made-up-icon")).toBe(FALLBACK_DRILL_ICON); expect(resolveDrillIcon(undefined)).toBe(FALLBACK_DRILL_ICON); + expect(isSupportedDrillIcon("sparkle")).toBe(true); expect(isSupportedDrillIcon("sparkles")).toBe(true); expect(isSupportedDrillIcon("made-up-icon")).toBe(false); }); diff --git a/apps/mobile/src/features/drill/drill-icons.ts b/apps/mobile/src/features/drill/drill-icons.ts index 4a29cb9d..a0b065e7 100644 --- a/apps/mobile/src/features/drill/drill-icons.ts +++ b/apps/mobile/src/features/drill/drill-icons.ts @@ -4,6 +4,7 @@ import { Flag, Music2, Shapes, + Sparkle, Sparkles, Star, Trophy, @@ -22,6 +23,7 @@ export const DRILL_ICON_REGISTRY = Object.freeze({ flag: Flag, "music-2": Music2, shapes: Shapes, + sparkle: Sparkle, sparkles: Sparkles, star: Star, trophy: Trophy, diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts index 71f89d0c..90805998 100644 --- a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts +++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts @@ -16,6 +16,8 @@ describe("Field overlay layout", () => { expect(layout.hudStyle.top).toBe(38); expect(layout.hudStyle.left).toBe(24); expect(layout.hudWidth).toBeGreaterThan(0); + expect(layout.liveStyle.right).toBe(10); + expect(layout.dialStyle.right).toBe(10); const topGap = Number(layout.liveStyle.top) - insets.top; const betweenGap = Number(layout.dialStyle.top) - @@ -29,6 +31,18 @@ describe("Field overlay layout", () => { expect(bottomGap).toBeCloseTo(layout.controlGap); }); + test("caps the landscape right margin when the safe-area inset is large", () => { + const layout = getFieldOverlayMetrics({ + width: 844, + height: 390, + landscape: true, + insets: { top: 24, right: 48, bottom: 20, left: 10 }, + }); + + expect(layout.liveStyle.right).toBe(layout.outerPadding); + expect(layout.dialStyle.right).toBe(layout.outerPadding); + }); + test("centers the live/dial pair above the bottom inset in portrait", () => { const layout = getFieldOverlayMetrics({ width: 390, diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx index 38206a07..4e49d20b 100644 --- a/apps/mobile/src/features/field/field-overlay-layout.tsx +++ b/apps/mobile/src/features/field/field-overlay-layout.tsx @@ -39,6 +39,7 @@ export function getFieldOverlayMetrics({ const safeHeight = Math.max(0, height - insets.top - insets.bottom); if (landscape) { + const landscapeEdgePadding = 8; const maximumFittingDiameter = Math.max( 0, (safeHeight - outerPadding * 3) / 2, @@ -46,7 +47,13 @@ export function getFieldOverlayMetrics({ const controlDiameter = Math.min(164, maximumFittingDiameter); const controlGap = Math.max(0, (safeHeight - controlDiameter * 2) / 3); const stackTop = insets.top + controlGap; - const right = insets.right + outerPadding; + // NativeTabs are hidden on the landscape Field route. Keep the controls + // close to the physical screen edge instead of stacking the full safe-area + // inset and normal HUD padding, which leaves an oversized dead strip. + const right = Math.max( + landscapeEdgePadding, + Math.min(insets.right, outerPadding), + ); const columnLeft = width - right - controlDiameter; const hudLeft = insets.left + outerPadding; const hudWidth = controlPairVisible From 6d85313949072c4076c3786226b01e07ebe56ef6 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 05:24:15 -0500 Subject: [PATCH 097/101] fix(mobile): Refine drill controls and dial wrapping --- .../drill/components/drill-list-item.tsx | 10 +++---- .../__tests__/field-overlay-layout.test.ts | 10 +++---- .../__tests__/live-position-hud-state.test.ts | 9 ++++++ .../features/field/field-overlay-layout.tsx | 13 ++++----- .../features/field/live-position-hud-state.ts | 7 +++-- .../src/features/field/live-position-hud.tsx | 1 + .../__tests__/page-dial-math.test.ts | 25 +++++++++++++++++ .../field/page-dial/page-dial-math.ts | 28 ++++++++----------- 8 files changed, 66 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/drill/components/drill-list-item.tsx b/apps/mobile/src/features/drill/components/drill-list-item.tsx index daa2cbc0..64080de2 100644 --- a/apps/mobile/src/features/drill/components/drill-list-item.tsx +++ b/apps/mobile/src/features/drill/components/drill-list-item.tsx @@ -110,7 +110,7 @@ export const DrillListItem = React.memo(function DrillListItem({ {countLabel} - + ({ - width: 46, - height: 46, + width: 40, + height: 40, alignItems: "center", justifyContent: "center", opacity: busy ? 0.45 : pressed ? 0.6 : 1, @@ -179,8 +179,8 @@ function DrillActionButton({ accessibilityState={{ disabled }} hitSlop={6} style={({ pressed }) => ({ - width: 42, - height: 42, + width: 36, + height: 36, alignItems: "center", justifyContent: "center", opacity: disabled ? 0.45 : pressed ? 0.6 : 1, diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts index 90805998..e6ee1988 100644 --- a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts +++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts @@ -16,8 +16,8 @@ describe("Field overlay layout", () => { expect(layout.hudStyle.top).toBe(38); expect(layout.hudStyle.left).toBe(24); expect(layout.hudWidth).toBeGreaterThan(0); - expect(layout.liveStyle.right).toBe(10); - expect(layout.dialStyle.right).toBe(10); + expect(layout.liveStyle.right).toBe(4); + expect(layout.dialStyle.right).toBe(4); const topGap = Number(layout.liveStyle.top) - insets.top; const betweenGap = Number(layout.dialStyle.top) - @@ -31,7 +31,7 @@ describe("Field overlay layout", () => { expect(bottomGap).toBeCloseTo(layout.controlGap); }); - test("caps the landscape right margin when the safe-area inset is large", () => { + test("keeps the landscape control stack close to the physical edge on iPhone", () => { const layout = getFieldOverlayMetrics({ width: 844, height: 390, @@ -39,8 +39,8 @@ describe("Field overlay layout", () => { insets: { top: 24, right: 48, bottom: 20, left: 10 }, }); - expect(layout.liveStyle.right).toBe(layout.outerPadding); - expect(layout.dialStyle.right).toBe(layout.outerPadding); + expect(layout.liveStyle.right).toBe(4); + expect(layout.dialStyle.right).toBe(4); }); test("centers the live/dial pair above the bottom inset in portrait", () => { diff --git a/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts index e2bbd79e..680f40d5 100644 --- a/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts +++ b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts @@ -34,6 +34,15 @@ describe("live position HUD state", () => { yellowThresholdSteps: 1, }).tone, ).toBe("danger"); + expect( + getTargetDistancePresentation({ + live, + target: { xMeters: 0.5715 * 0.62, yMeters: 0 }, + greenThresholdSteps: 1, + yellowThresholdSteps: 2, + roundingSteps: 0.125, + }).value, + ).toBe("0.625 steps"); expect( getTargetDistancePresentation({ live: { ...live, isStale: true }, diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx index 4e49d20b..572f4db9 100644 --- a/apps/mobile/src/features/field/field-overlay-layout.tsx +++ b/apps/mobile/src/features/field/field-overlay-layout.tsx @@ -39,7 +39,7 @@ export function getFieldOverlayMetrics({ const safeHeight = Math.max(0, height - insets.top - insets.bottom); if (landscape) { - const landscapeEdgePadding = 8; + const landscapeEdgePadding = 4; const maximumFittingDiameter = Math.max( 0, (safeHeight - outerPadding * 3) / 2, @@ -47,13 +47,10 @@ export function getFieldOverlayMetrics({ const controlDiameter = Math.min(164, maximumFittingDiameter); const controlGap = Math.max(0, (safeHeight - controlDiameter * 2) / 3); const stackTop = insets.top + controlGap; - // NativeTabs are hidden on the landscape Field route. Keep the controls - // close to the physical screen edge instead of stacking the full safe-area - // inset and normal HUD padding, which leaves an oversized dead strip. - const right = Math.max( - landscapeEdgePadding, - Math.min(insets.right, outerPadding), - ); + // NativeTabs are hidden on the landscape Field route, so the live/dial + // stack should hug the physical screen edge instead of inheriting the + // landscape safe-area inset that made iPhone spacing look oversized. + const right = landscapeEdgePadding; const columnLeft = width - right - controlDiameter; const hudLeft = insets.left + outerPadding; const hudWidth = controlPairVisible diff --git a/apps/mobile/src/features/field/live-position-hud-state.ts b/apps/mobile/src/features/field/live-position-hud-state.ts index b2807f8b..db99aba2 100644 --- a/apps/mobile/src/features/field/live-position-hud-state.ts +++ b/apps/mobile/src/features/field/live-position-hud-state.ts @@ -4,6 +4,7 @@ import { fieldPointToMarchingCoordinate, formatMarchingFrontBack, formatMarchingSide, + formatMarchingSteps, metersToStandardSteps, type FieldLivePositionState, type FieldPoint, @@ -41,11 +42,13 @@ export function getTargetDistancePresentation({ target, greenThresholdSteps, yellowThresholdSteps, + roundingSteps = 0.25, }: { readonly live: FieldLivePositionState; readonly target?: FieldPoint; readonly greenThresholdSteps: number; readonly yellowThresholdSteps: number; + readonly roundingSteps?: CoordinateRoundingSteps; }): TargetDistancePresentation { if (!live.position || live.isStale || !target) { return { value: "–", tone: "muted" }; @@ -56,10 +59,10 @@ export function getTargetDistancePresentation({ live.position.yMeters - target.yMeters, ); const steps = metersToStandardSteps(distanceMeters); - const roundedSteps = Number(steps.toFixed(1)); + const roundedSteps = formatMarchingSteps(steps, roundingSteps); return { steps, - value: roundedSteps === 1 ? "one step" : `${roundedSteps.toFixed(1)} steps`, + value: Number(roundedSteps) === 1 ? "one step" : `${roundedSteps} steps`, tone: steps <= greenThresholdSteps ? "success" diff --git a/apps/mobile/src/features/field/live-position-hud.tsx b/apps/mobile/src/features/field/live-position-hud.tsx index ae11b446..5428fc38 100644 --- a/apps/mobile/src/features/field/live-position-hud.tsx +++ b/apps/mobile/src/features/field/live-position-hud.tsx @@ -60,6 +60,7 @@ export function LivePositionSquare({ target, greenThresholdSteps, yellowThresholdSteps, + roundingSteps: coordinateRoundingSteps, }); const distanceColor = colorForDistanceTone(distance.tone, theme); const dividerThickness = 1; diff --git a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts index 88d3309f..b6ce450f 100644 --- a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts +++ b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts @@ -49,6 +49,31 @@ describe("page dial math", () => { expect( pageDialProgressForPointNearReference(center, topY, diameter, 0.98), ).toBe(1); + + const clockwisePastTop = pageDialPointForProgress(0.01, diameter, 90); + expect( + pageDialProgressForPointNearReference( + clockwisePastTop.x, + clockwisePastTop.y, + diameter, + 1, + ), + ).toBeCloseTo(0.01); + + const counterClockwisePastTop = pageDialPointForProgress( + 0.99, + diameter, + 90, + ); + expect( + pageDialProgressForPointNearReference( + counterClockwisePastTop.x, + counterClockwisePastTop.y, + diameter, + 0, + ), + ).toBeCloseTo(0.99); + expect(pageDialIndexForAngle(radians(-89), 38)).toBe(0); expect(pageDialIndexForAngle(radians(-91), 38)).toBe(37); expect(pageDialIndexForAngle(radians(90), 5)).toBe(2); diff --git a/apps/mobile/src/features/field/page-dial/page-dial-math.ts b/apps/mobile/src/features/field/page-dial/page-dial-math.ts index 9ce247de..777c5ae7 100644 --- a/apps/mobile/src/features/field/page-dial/page-dial-math.ts +++ b/apps/mobile/src/features/field/page-dial/page-dial-math.ts @@ -114,11 +114,11 @@ export function pageDialProgressForAngle(angleRadians: number): number { } /** - * Resolve the top seam against the user's current drag position. The top point - * is intentionally shared by progress 0 (first set) and progress 1 (last set). - * Approaching it clockwise from the end of the ring resolves to 1; approaching - * counter-clockwise from the start resolves to 0. Continuing beyond either - * endpoint clamps there rather than wrapping unexpectedly to the other end. + * Resolve the shared top seam while still allowing the dial to wrap forever. + * Exactly at the top, drag history decides whether the knob represents the + * first set (0) or last set (1). Once the pointer crosses the seam, the wrapped + * angular progress is returned immediately so clockwise motion moves last → + * first and counter-clockwise motion moves first → last on every revolution. */ export function pageDialProgressForAngleNearReference( angleRadians: number, @@ -132,19 +132,13 @@ export function pageDialProgressForAngleNearReference( 1, ); - let candidate = wrapped; - if (wrapped <= 0.5) { - const afterEnd = wrapped + 1; - if (Math.abs(afterEnd - reference) < Math.abs(candidate - reference)) { - candidate = afterEnd; - } - } else { - const beforeStart = wrapped - 1; - if (Math.abs(beforeStart - reference) < Math.abs(candidate - reference)) { - candidate = beforeStart; - } + // atan2 resolves the exact top point to the start angle, so use the prior + // state to preserve the intentional first/last ambiguity at that one point. + if (wrapped === 0) { + return reference > 0.5 ? 1 : 0; } - return clamp(candidate, 0, 1); + + return wrapped; } export function pageDialProgressForPoint( From 4ed5c970017608905554db0db56c69f42997e11f Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 05:33:21 -0500 Subject: [PATCH 098/101] fix(field): Balance landscape control padding --- .../field/__tests__/field-overlay-layout.test.ts | 13 ++++++++----- .../src/features/field/field-overlay-layout.tsx | 8 ++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts index e6ee1988..edc30071 100644 --- a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts +++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts @@ -16,8 +16,11 @@ describe("Field overlay layout", () => { expect(layout.hudStyle.top).toBe(38); expect(layout.hudStyle.left).toBe(24); expect(layout.hudWidth).toBeGreaterThan(0); - expect(layout.liveStyle.right).toBe(4); - expect(layout.dialStyle.right).toBe(4); + expect(layout.liveStyle.right).toBe(layout.outerPadding); + expect(layout.dialStyle.right).toBe(layout.outerPadding); + expect(layout.liveStyle.right).toBe( + Number(layout.hudStyle.top) - insets.top, + ); const topGap = Number(layout.liveStyle.top) - insets.top; const betweenGap = Number(layout.dialStyle.top) - @@ -31,7 +34,7 @@ describe("Field overlay layout", () => { expect(bottomGap).toBeCloseTo(layout.controlGap); }); - test("keeps the landscape control stack close to the physical edge on iPhone", () => { + test("ignores the large iPhone side inset but keeps balanced landscape padding", () => { const layout = getFieldOverlayMetrics({ width: 844, height: 390, @@ -39,8 +42,8 @@ describe("Field overlay layout", () => { insets: { top: 24, right: 48, bottom: 20, left: 10 }, }); - expect(layout.liveStyle.right).toBe(4); - expect(layout.dialStyle.right).toBe(4); + expect(layout.liveStyle.right).toBe(layout.outerPadding); + expect(layout.dialStyle.right).toBe(layout.outerPadding); }); test("centers the live/dial pair above the bottom inset in portrait", () => { diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx index 572f4db9..07f483d9 100644 --- a/apps/mobile/src/features/field/field-overlay-layout.tsx +++ b/apps/mobile/src/features/field/field-overlay-layout.tsx @@ -39,7 +39,7 @@ export function getFieldOverlayMetrics({ const safeHeight = Math.max(0, height - insets.top - insets.bottom); if (landscape) { - const landscapeEdgePadding = 4; + const landscapeEdgePadding = outerPadding; const maximumFittingDiameter = Math.max( 0, (safeHeight - outerPadding * 3) / 2, @@ -47,9 +47,9 @@ export function getFieldOverlayMetrics({ const controlDiameter = Math.min(164, maximumFittingDiameter); const controlGap = Math.max(0, (safeHeight - controlDiameter * 2) / 3); const stackTop = insets.top + controlGap; - // NativeTabs are hidden on the landscape Field route, so the live/dial - // stack should hug the physical screen edge instead of inheriting the - // landscape safe-area inset that made iPhone spacing look oversized. + // NativeTabs are hidden on the landscape Field route. Match the right + // visual margin to the same padding used between the status-bar safe area + // and the top HUD instead of inheriting the much larger side safe inset. const right = landscapeEdgePadding; const columnLeft = width - right - controlDiameter; const hudLeft = insets.left + outerPadding; From 2775f13c2a32c809e11c5935fa6bee1e68ec4656 Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 05:39:45 -0500 Subject: [PATCH 099/101] fix(settings): Simplify tag page title --- apps/mobile/app/(tabs)/settings/_layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app/(tabs)/settings/_layout.tsx b/apps/mobile/app/(tabs)/settings/_layout.tsx index b3eb4e8c..4a48cb66 100644 --- a/apps/mobile/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile/app/(tabs)/settings/_layout.tsx @@ -19,7 +19,7 @@ export default function SettingsLayout() { }} > - + Date: Thu, 6 Aug 2026 05:59:57 -0500 Subject: [PATCH 100/101] fix(pans): Broadcast anchor names from hardware --- .../settings/__tests__/anchor-display.test.ts | 18 +++++- .../src/features/settings/anchor-display.ts | 3 +- .../settings/anchor-editor-screen.tsx | 32 +++++------ .../settings/use-anchor-editor-controller.ts | 35 ++++++------ .../pans/__tests__/mobile-pans-store.test.ts | 56 +++++++++++++------ apps/mobile/src/pans/mobile-pans-store.ts | 44 ++++++++++----- 6 files changed, 120 insertions(+), 68 deletions(-) diff --git a/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts index 90e2e005..67bfb70c 100644 --- a/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts +++ b/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts @@ -11,16 +11,28 @@ const anchor = (overrides: Partial = {}): ManagedDevice => ({ }); describe("developer anchor display names", () => { - test("prefers the local name and falls back to hardware identifiers", () => { + test("prefers the hardware PANS label and falls back to identifiers", () => { expect( getDeveloperAnchorDisplayName( - anchor({ nickname: " Front 50 ", nodeIdHex: "A001", label: "HW" }), + anchor({ + nickname: "Legacy local name", + nodeIdHex: "A001", + label: "HW", + lastKnownConfig: { + role: "anchor", + label: " Front 50 ", + uwbMode: "active", + ledEnabled: true, + firmwareUpdateEnabled: false, + initiatorEnabled: false, + }, + }), ), ).toBe("Front 50"); + expect(getDeveloperAnchorDisplayName(anchor({ label: "HW" }))).toBe("HW"); expect(getDeveloperAnchorDisplayName(anchor({ nodeIdHex: "A001" }))).toBe( "A001", ); - expect(getDeveloperAnchorDisplayName(anchor({ label: "HW" }))).toBe("HW"); expect(getDeveloperAnchorDisplayName(anchor())).toBe("anchor-id"); }); }); diff --git a/apps/mobile/src/features/settings/anchor-display.ts b/apps/mobile/src/features/settings/anchor-display.ts index 3778397b..83ccfe91 100644 --- a/apps/mobile/src/features/settings/anchor-display.ts +++ b/apps/mobile/src/features/settings/anchor-display.ts @@ -2,10 +2,9 @@ import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; export function getDeveloperAnchorDisplayName(anchor: ManagedDevice): string { return ( - anchor.nickname?.trim() || - anchor.nodeIdHex?.trim() || anchor.lastKnownConfig?.label?.trim() || anchor.label?.trim() || + anchor.nodeIdHex?.trim() || anchor.id ); } diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx index 159f9617..896861c3 100644 --- a/apps/mobile/src/features/settings/anchor-editor-screen.tsx +++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx @@ -97,45 +97,43 @@ export function AnchorEditorScreen({ > - Local display name + Anchor name - + - Stored only on this device and never written to the anchor. + Written to the PANS device name and advertised over Bluetooth. diff --git a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts index f9c4c29c..12de1a4e 100644 --- a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts +++ b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts @@ -42,10 +42,10 @@ export function useAnchorEditorController(anchorId: string) { const [standardDraft, setStandardDraft] = React.useState( () => createAnchorEditorDrafts().standard, ); - const [localName, setLocalName] = React.useState(""); + const [anchorName, setAnchorName] = React.useState(""); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); - const [savingLocalName, setSavingLocalName] = React.useState(false); + const [savingName, setSavingName] = React.useState(false); const [saved, setSaved] = React.useState(false); const [error, setError] = React.useState(); @@ -78,7 +78,7 @@ export function useAnchorEditorController(anchorId: string) { ); const drafts = createAnchorEditorDrafts(position, nextFieldPreset); setAnchor(next); - setLocalName(next.nickname ?? ""); + setAnchorName(next.lastKnownConfig?.label ?? next.label ?? ""); setFieldPreset(nextFieldPreset); setMarchingDraft(drafts.marching); setStandardDraft(drafts.standard); @@ -172,23 +172,22 @@ export function useAnchorEditorController(anchorId: string) { ); }; - const saveLocalName = async () => { - if (!anchor || savingLocalName || !settings.settings.developerModeEnabled) { + const saveAnchorName = async () => { + if (!anchor || savingName || !settings.settings.developerModeEnabled) { return; } - setSavingLocalName(true); + setSavingName(true); setError(undefined); try { - const savedAnchor = await pansStore.setAnchorLocalName( - anchor.id, - localName, - ); + const savedAnchor = await pansStore.renameAnchor(anchor.id, anchorName); setAnchor(savedAnchor); - setLocalName(savedAnchor.nickname ?? ""); + setAnchorName( + savedAnchor.lastKnownConfig?.label ?? savedAnchor.label ?? "", + ); } catch (cause) { setError(cause instanceof Error ? cause : new Error(String(cause))); } finally { - setSavingLocalName(false); + setSavingName(false); } }; @@ -219,16 +218,18 @@ export function useAnchorEditorController(anchorId: string) { marchingDraft, standardDraft, validation, - localName, - localNameDirty: localName.trim() !== (anchor?.nickname ?? ""), + anchorName, + anchorNameDirty: + anchorName.trim() !== + (anchor?.lastKnownConfig?.label ?? anchor?.label ?? "").trim(), loading, saving, - savingLocalName, + savingName, saved, error, setMode, - setLocalName, - saveLocalName, + setAnchorName, + saveAnchorName, setMarchingDraft: (draft: MarchingAnchorDraft) => { setSaved(false); setMarchingDraft(draft); diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts index 8ada8787..2d9aa638 100644 --- a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts +++ b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts @@ -438,7 +438,7 @@ describe("MobilePansStore", () => { await store.dispose(); }); - test("stores a developer-only local anchor name and refreshes the cache", async () => { + test("writes an anchor name to the hardware PANS label and refreshes the cache", async () => { const harness = await createHarness(); const store = new MobilePansStore({ createRuntime: async () => harness.runtime, @@ -448,19 +448,18 @@ describe("MobilePansStore", () => { await harness.repository.saveDevice(managedAnchor("named-anchor")); await store.refreshCachedAnchors(); - const saved = await store.setAnchorLocalName( - "named-anchor", - " Front 50 ", - ); + const saved = await store.renameAnchor("named-anchor", " Front 50 "); - expect(saved.nickname).toBe("Front 50"); - expect((await harness.repository.getDevice("named-anchor"))?.nickname).toBe( - "Front 50", - ); + expect(saved.lastKnownConfig?.label).toBe("Front 50"); + expect(harness.configurationApply).toHaveBeenCalledWith("named-anchor", { + label: "Front 50", + }); expect(store.getSnapshot().knownAnchors).toContainEqual( - expect.objectContaining({ id: "named-anchor", nickname: "Front 50" }), + expect.objectContaining({ + id: "named-anchor", + lastKnownConfig: expect.objectContaining({ label: "Front 50" }), + }), ); - expect(harness.configurationApply).not.toHaveBeenCalled(); await store.dispose(); }); @@ -524,17 +523,42 @@ async function createHarness( >(); const streamStart = options.streamStart ?? jest.fn(async () => undefined); const configurationApply = jest.fn( - async (deviceId: string, changes: { position: PansPosition }) => { + async ( + deviceId: string, + changes: { position?: PansPosition; label?: string }, + ) => { const device = (await repository.getDevice(deviceId))!; + const baseConfig = + device.lastKnownConfig?.role === "anchor" + ? device.lastKnownConfig + : anchorConfig(); await repository.saveDevice({ ...device, + ...(changes.label !== undefined ? { label: changes.label } : {}), lastKnownConfig: { - ...(device.lastKnownConfig?.role === "anchor" - ? device.lastKnownConfig - : anchorConfig()), - position: changes.position, + ...baseConfig, + ...(changes.position !== undefined + ? { position: changes.position } + : {}), + ...(changes.label !== undefined ? { label: changes.label } : {}), }, }); + if (changes.label !== undefined) { + return { + deviceId, + transportDeviceId: device.transportDeviceId, + outcome: "applied" as const, + writes: [ + { + field: "label", + status: "verified" as const, + requested: changes.label, + actual: changes.label, + }, + ], + warnings: [], + }; + } return { deviceId, transportDeviceId: device.transportDeviceId, diff --git a/apps/mobile/src/pans/mobile-pans-store.ts b/apps/mobile/src/pans/mobile-pans-store.ts index 977cc664..cf05822f 100644 --- a/apps/mobile/src/pans/mobile-pans-store.ts +++ b/apps/mobile/src/pans/mobile-pans-store.ts @@ -701,16 +701,15 @@ export class MobilePansStore { return knownAnchors; } - async setAnchorLocalName( - anchorId: string, - localName: string, - ): Promise { + async renameAnchor(anchorId: string, label: string): Promise { if (!this.developerModeEnabled) { throw new ManagerError( "INVALID_CONFIGURATION", - "Enable Developer Mode before naming cached anchors.", + "Enable Developer Mode before renaming anchors.", ); } + const requestedLabel = label.trim(); + assertValidLabel(requestedLabel); const runtime = this.requireRuntime(); const anchor = await runtime.repository.getDevice(anchorId); if ( @@ -719,14 +718,33 @@ export class MobilePansStore { ) { throw new Error("The selected cached anchor does not exist."); } - const nickname = localName.trim() || undefined; - const saved = await runtime.repository.saveDevice({ - ...anchor, - nickname, - updatedAt: Date.now(), - }); - await this.refreshCachedAnchors(); - return saved; + try { + await this.runHardwareOperation(async () => { + const result = await runtime.configuration.applyConfigurationDiff( + anchor.id, + { label: requestedLabel }, + ); + const write = result.writes.find((item) => item.field === "label"); + if ( + result.error || + write?.status === "failed" || + write?.status === "mismatch" + ) { + throw new ManagerError( + result.error?.code ?? "WRITE_FAILED", + result.error?.message ?? "The anchor name could not be verified.", + { deviceId: anchor.id, operation: "rename anchor" }, + ); + } + }); + await this.refreshCachedAnchors(); + return (await runtime.repository.getDevice(anchor.id)) ?? anchor; + } catch (cause) { + throw normalizeManagerError(cause, { + deviceId: anchor.id, + operation: "rename anchor", + }); + } } async writeAnchorPosition( From aad3006675d54aec036387117c533574c5f5626a Mon Sep 17 00:00:00 2001 From: Colin Guth Date: Thu, 6 Aug 2026 06:00:17 -0500 Subject: [PATCH 101/101] chore: Bump workspace versions to 0.1.0 --- apps/drill-converter/app.config.ts | 2 +- apps/drill-converter/package.json | 8 ++--- apps/testbed/app.config.ts | 2 +- apps/testbed/package.json | 2 +- .../expo-pans-ble-api/android/build.gradle | 2 +- modules/expo-pans-ble-api/package.json | 2 +- package-lock.json | 30 +++++++++---------- package.json | 2 +- packages/drill-importers/package.json | 4 +-- packages/drill-schema/package.json | 2 +- packages/mobile/package.json | 4 +-- packages/ui/package.json | 4 +-- 12 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/drill-converter/app.config.ts b/apps/drill-converter/app.config.ts index ea9d2d83..cfd186dc 100644 --- a/apps/drill-converter/app.config.ts +++ b/apps/drill-converter/app.config.ts @@ -3,7 +3,7 @@ import type { ExpoConfig } from "expo/config"; const config: ExpoConfig = { name: "Eight2Five Drill Converter", slug: "eight2five-drill-converter", - version: "0.0.0", + version: "0.1.0", platforms: ["web"], userInterfaceStyle: "automatic", web: { diff --git a/apps/drill-converter/package.json b/apps/drill-converter/package.json index 346712d3..2d1b9ce1 100644 --- a/apps/drill-converter/package.json +++ b/apps/drill-converter/package.json @@ -1,6 +1,6 @@ { "name": "eight2five-drill-converter", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "expo-router/entry", "scripts": { @@ -13,9 +13,9 @@ "test": "jest --watchAll=false --passWithNoTests --runInBand" }, "dependencies": { - "@eight2five/drill-importers": "0.0.0", - "@eight2five/drill-schema": "0.0.0", - "@eight2five/ui": "0.0.0", + "@eight2five/drill-importers": "0.1.0", + "@eight2five/drill-schema": "0.1.0", + "@eight2five/ui": "0.1.0", "@expo/metro-runtime": "~57.0.8", "expo": "~57.0.10", "expo-document-picker": "~57.0.1", diff --git a/apps/testbed/app.config.ts b/apps/testbed/app.config.ts index 719cc1ab..c7f9a060 100644 --- a/apps/testbed/app.config.ts +++ b/apps/testbed/app.config.ts @@ -19,7 +19,7 @@ const config: ExpoConfig = { slug: "eight2five-testbed", scheme: "eight2five-testbed", platforms: ["ios", "android"], - version: "0.0.0", + version: "0.1.0", orientation: "portrait", icon: "./assets/app-icons/testbed-android-legacy-icon.png", userInterfaceStyle: "automatic", diff --git a/apps/testbed/package.json b/apps/testbed/package.json index 504bcc26..ba2e8832 100644 --- a/apps/testbed/package.json +++ b/apps/testbed/package.json @@ -1,6 +1,6 @@ { "name": "eight2five-testbed", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "expo-router/entry", "scripts": { diff --git a/modules/expo-pans-ble-api/android/build.gradle b/modules/expo-pans-ble-api/android/build.gradle index ee734af1..09f2cf9f 100644 --- a/modules/expo-pans-ble-api/android/build.gradle +++ b/modules/expo-pans-ble-api/android/build.gradle @@ -6,7 +6,7 @@ android { defaultConfig { versionCode 1 - versionName "0.0.0" + versionName "0.1.0" consumerProguardFiles 'consumer-rules.pro' } diff --git a/modules/expo-pans-ble-api/package.json b/modules/expo-pans-ble-api/package.json index 57941165..d61eaf4d 100644 --- a/modules/expo-pans-ble-api/package.json +++ b/modules/expo-pans-ble-api/package.json @@ -1,6 +1,6 @@ { "name": "expo-pans-ble-api", - "version": "0.0.0", + "version": "0.1.0", "main": "index.ts", "types": "index.ts", "scripts": { diff --git a/package-lock.json b/package-lock.json index d6f7f2ca..a91ceb13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eight2five", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eight2five", - "version": "0.0.0", + "version": "0.1.0", "workspaces": [ "apps/*", "packages/*", @@ -23,11 +23,11 @@ }, "apps/drill-converter": { "name": "eight2five-drill-converter", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { - "@eight2five/drill-importers": "0.0.0", - "@eight2five/drill-schema": "0.0.0", - "@eight2five/ui": "0.0.0", + "@eight2five/drill-importers": "0.1.0", + "@eight2five/drill-schema": "0.1.0", + "@eight2five/ui": "0.1.0", "@expo/metro-runtime": "~57.0.8", "expo": "~57.0.10", "expo-document-picker": "~57.0.1", @@ -192,7 +192,7 @@ }, "apps/testbed": { "name": "eight2five-testbed", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", @@ -290,7 +290,7 @@ } }, "modules/expo-pans-ble-api": { - "version": "0.0.0", + "version": "0.1.0", "devDependencies": { "@types/jest": "^29.5.14", "jest": "^29.7.0" @@ -19059,23 +19059,23 @@ }, "packages/drill-importers": { "name": "@eight2five/drill-importers", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { - "@eight2five/drill-schema": "0.0.0" + "@eight2five/drill-schema": "0.1.0" } }, "packages/drill-schema": { "name": "@eight2five/drill-schema", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "zod": "^3.25.76" } }, "packages/mobile": { "name": "@eight2five/mobile", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { - "@eight2five/drill-schema": "0.0.0", + "@eight2five/drill-schema": "0.1.0", "@expo-google-fonts/montserrat": "^0.4.2", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", @@ -19251,9 +19251,9 @@ }, "packages/ui": { "name": "@eight2five/ui", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { - "@eight2five/drill-schema": "0.0.0", + "@eight2five/drill-schema": "0.1.0", "@expo-google-fonts/montserrat": "^0.4.2", "@expo-google-fonts/source-sans-3": "^0.4.1", "@expo/html-elements": "^0.12.5", diff --git a/package.json b/package.json index 71f84a70..a641c6b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eight2five", - "version": "0.0.0", + "version": "0.1.0", "private": true, "workspaces": [ "apps/*", diff --git a/packages/drill-importers/package.json b/packages/drill-importers/package.json index 2ecd252e..fd2d9cc2 100644 --- a/packages/drill-importers/package.json +++ b/packages/drill-importers/package.json @@ -1,6 +1,6 @@ { "name": "@eight2five/drill-importers", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "src/index.ts", "types": "src/index.ts", @@ -12,7 +12,7 @@ "test": "jest src --watchAll=false --passWithNoTests --runInBand" }, "dependencies": { - "@eight2five/drill-schema": "0.0.0" + "@eight2five/drill-schema": "0.1.0" }, "jest": { "testEnvironment": "node", diff --git a/packages/drill-schema/package.json b/packages/drill-schema/package.json index 7340e31c..d3f3c36a 100644 --- a/packages/drill-schema/package.json +++ b/packages/drill-schema/package.json @@ -1,6 +1,6 @@ { "name": "@eight2five/drill-schema", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6328dadc..1abc9481 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@eight2five/mobile", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "src/index.ts", "types": "src/index.ts", @@ -28,7 +28,7 @@ "react-native": "0.86.2" }, "dependencies": { - "@eight2five/drill-schema": "0.0.0", + "@eight2five/drill-schema": "0.1.0", "@expo-google-fonts/montserrat": "^0.4.2", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", diff --git a/packages/ui/package.json b/packages/ui/package.json index cb2698cb..f3121b11 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@eight2five/ui", - "version": "0.0.0", + "version": "0.1.0", "private": true, "exports": { "./components/box": { @@ -72,7 +72,7 @@ "./theme": "./theme/index.tsx" }, "dependencies": { - "@eight2five/drill-schema": "0.0.0", + "@eight2five/drill-schema": "0.1.0", "@expo-google-fonts/montserrat": "^0.4.2", "@expo-google-fonts/source-sans-3": "^0.4.1", "@expo/html-elements": "^0.12.5",