From 749f5749ab2a23ead9093f09d39f07a4e8b09776 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Sun, 22 Mar 2026 09:34:12 -0400 Subject: [PATCH 01/49] chore: snapshot ui core app centralization state --- .../ui-core-app-centralization/design.md | 135 ++++ .../specs/ui-core-app-centralization/plan.md | 73 ++ .../specs/ui-core-app-centralization/tasks.md | 22 + apps/mobile/app/(external)/onboarding.tsx | 152 +--- apps/mobile/app/(external)/reset-password.tsx | 47 +- apps/mobile/app/(external)/sign-in.tsx | 13 +- apps/mobile/app/(external)/sign-up.tsx | 63 +- apps/mobile/app/(external)/verify.tsx | 52 +- .../activity-plan-detail-scheduling.test.tsx | 24 +- .../scheduled-activities-list.test.tsx | 5 +- .../__tests__/training-plan-deeplink.test.tsx | 105 +-- .../training-plans-list-screen.test.tsx | 18 +- .../training-preferences-preview.test.tsx | 81 +- .../__tests__/user-detail-screen.test.tsx | 70 +- .../(internal)/(standard)/activities-list.tsx | 85 +- .../(internal)/(standard)/activity-detail.tsx | 206 ++--- .../(standard)/activity-plan-detail.tsx | 328 +++----- .../(standard)/notifications/index.tsx | 109 ++- .../app/(internal)/(standard)/onboarding.tsx | 15 +- .../(standard)/scheduled-activities-list.tsx | 29 +- .../(standard)/training-plan-detail.tsx | 552 ++++--------- .../(standard)/training-plans-list.tsx | 54 +- .../(standard)/training-preferences.tsx | 266 ++----- .../(internal)/(standard)/user/[userId].tsx | 195 ++--- .../mobile/app/(internal)/(tabs)/discover.tsx | 108 +-- apps/mobile/app/(internal)/record/plan.tsx | 89 +-- apps/mobile/app/(internal)/record/route.tsx | 77 +- apps/mobile/app/(internal)/record/sensors.tsx | 139 +--- apps/mobile/app/_layout.tsx | 45 +- apps/mobile/components/ActivityListModal.tsx | 75 +- .../components/ActivitySelectionModal.tsx | 167 ++-- .../components/ScheduleActivityModal.tsx | 160 ++-- apps/mobile/components/TimeRangeSelector.tsx | 68 +- .../CalendarPlannedActivityPickerModal.tsx | 92 +-- .../calendar/CalendarViewSegmentedControl.tsx | 53 +- .../components/goals/GoalEditorModal.tsx | 276 +++---- .../components/profile/PaceSecondsField.tsx | 8 +- .../components/profile/WeightInputField.tsx | 14 +- .../__tests__/profile-input-wrappers.test.tsx | 7 +- .../settings/TrainingZonesSection.tsx | 29 +- .../settings/__tests__/SettingsGroup.test.tsx | 83 -- apps/mobile/components/settings/index.ts | 7 +- apps/mobile/components/shared/index.ts | 8 +- .../training-plan/AdvancedConfigSheet.tsx | 103 +-- .../training-plan/QuickAdjustSheet.tsx | 103 +-- .../training-plan/WeeklyProgressCard.tsx | 41 +- .../training-plan/create/SinglePageForm.tsx | 737 +++++------------- .../training-plan/create/WizardStep.tsx | 29 +- .../SinglePageForm.blockers.test.tsx | 126 +-- .../inputs/__tests__/DateField.test.tsx | 13 +- .../inputs/__tests__/reusable-inputs.test.tsx | 104 --- .../create/steps/AvailabilityStep.tsx | 88 +-- .../create/tabs/ConstraintsTab.tsx | 60 +- .../create/tabs/InfluenceTab.tsx | 28 +- .../training-plan/forms/PeriodizationForm.tsx | 231 ++---- .../training-plan/forms/RecoveryRulesForm.tsx | 108 +-- .../training-plan/forms/WeeklyTargetsForm.tsx | 95 +-- .../modals/components/ActivitySelector.tsx | 23 +- .../components/trends/ConsistencyTab.tsx | 100 +-- apps/mobile/components/trends/FitnessTab.tsx | 70 +- .../mobile/components/trends/IntensityTab.tsx | 47 +- apps/mobile/components/trends/OverviewTab.tsx | 122 ++- .../components/trends/PerformanceTab.tsx | 45 +- apps/mobile/components/trends/VolumeTab.tsx | 50 +- apps/mobile/components/trends/WeeklyTab.tsx | 23 +- apps/mobile/lib/goals/goalDraft.ts | 610 +-------------- apps/mobile/lib/hooks/useAuth.ts | 17 +- .../lib/training-plan-form/input-parsers.ts | 203 +---- .../lib/training-plan-form/validation.ts | 499 +----------- apps/mobile/lib/trpc.ts | 46 +- apps/mobile/package.json | 1 + apps/mobile/test/setup.ts | 8 +- apps/mobile/vitest.config.ts | 5 + .../src/app/(internal)/notifications/page.tsx | 156 ++-- apps/web/src/app/(internal)/settings/page.tsx | 166 ++-- .../integrations/callback/[provider]/route.ts | 30 +- apps/web/src/app/api/trpc/[trpc]/route.ts | 5 +- apps/web/src/app/api/webhooks/wahoo/route.ts | 29 +- .../src/components/notifications-button.tsx | 67 +- .../components/providers/auth-provider.tsx | 9 +- apps/web/src/lib/supabase/middlewear.ts | 9 +- apps/web/src/lib/supabase/server.ts | 37 +- packages/core/README.md | 15 +- .../forms/__tests__/input-parsers.test.ts | 43 + packages/core/forms/index.ts | 1 + packages/core/forms/input-parsers.ts | 180 +++++ packages/core/goals/__tests__/draft.test.ts | 105 +++ packages/core/goals/draft.ts | 566 ++++++++++++++ packages/core/goals/index.ts | 1 + packages/core/index.ts | 74 +- .../notifications/__tests__/index.test.ts | 79 ++ packages/core/notifications/index.ts | 139 ++++ packages/core/package.json | 7 + .../plan/__tests__/form-validation.test.ts | 142 ++++ packages/core/plan/formValidation.ts | 459 +++++++++++ packages/core/plan/index.ts | 49 +- packages/core/plan/trainingPlanPreview.ts | 92 +-- packages/core/schemas/coaching.ts | 40 +- packages/core/schemas/form-schemas.ts | 234 ++---- packages/core/schemas/messaging.ts | 25 +- packages/core/schemas/notifications.ts | 23 +- packages/trpc/src/context.ts | 26 +- packages/ui/package.json | 32 +- .../index.native.test.tsx | 27 + .../bounded-number-input/index.native.tsx | 46 +- .../component-coverage.web.test.tsx | 8 +- .../duration-input/index.native.test.tsx | 18 + .../duration-input/index.native.tsx | 32 +- .../empty-state-card/index.native.test.tsx | 21 + .../empty-state-card/index.native.tsx | 40 +- .../error-state-card/index.native.test.tsx | 16 + .../error-state-card/index.native.tsx | 60 +- packages/ui/src/components/index.ts | 1 + .../integer-stepper/index.native.test.tsx | 25 + .../integer-stepper/index.native.tsx | 28 +- .../pace-input/index.native.test.tsx | 18 + .../components/pace-input/index.native.tsx | 32 +- .../settings-group/index.native.test.tsx | 24 + .../settings-group/index.native.tsx | 73 +- .../src/components/switch/index.web.test.tsx | 19 + .../ui/src/components/switch/index.web.tsx | 33 + pnpm-lock.yaml | 9 + 122 files changed, 4844 insertions(+), 6465 deletions(-) create mode 100644 .opencode/specs/ui-core-app-centralization/design.md create mode 100644 .opencode/specs/ui-core-app-centralization/plan.md create mode 100644 .opencode/specs/ui-core-app-centralization/tasks.md delete mode 100644 apps/mobile/components/settings/__tests__/SettingsGroup.test.tsx create mode 100644 packages/core/forms/__tests__/input-parsers.test.ts create mode 100644 packages/core/forms/index.ts create mode 100644 packages/core/forms/input-parsers.ts create mode 100644 packages/core/goals/__tests__/draft.test.ts create mode 100644 packages/core/goals/draft.ts create mode 100644 packages/core/goals/index.ts create mode 100644 packages/core/notifications/__tests__/index.test.ts create mode 100644 packages/core/notifications/index.ts create mode 100644 packages/core/plan/__tests__/form-validation.test.ts create mode 100644 packages/core/plan/formValidation.ts create mode 100644 packages/ui/src/components/bounded-number-input/index.native.test.tsx rename apps/mobile/components/training-plan/create/inputs/BoundedNumberInput.tsx => packages/ui/src/components/bounded-number-input/index.native.tsx (71%) create mode 100644 packages/ui/src/components/duration-input/index.native.test.tsx rename apps/mobile/components/training-plan/create/inputs/DurationInput.tsx => packages/ui/src/components/duration-input/index.native.tsx (69%) create mode 100644 packages/ui/src/components/empty-state-card/index.native.test.tsx rename apps/mobile/components/shared/EmptyStateCard.tsx => packages/ui/src/components/empty-state-card/index.native.tsx (57%) create mode 100644 packages/ui/src/components/error-state-card/index.native.test.tsx rename apps/mobile/components/shared/ErrorStateCard.tsx => packages/ui/src/components/error-state-card/index.native.tsx (78%) create mode 100644 packages/ui/src/components/integer-stepper/index.native.test.tsx rename apps/mobile/components/training-plan/create/inputs/IntegerStepper.tsx => packages/ui/src/components/integer-stepper/index.native.tsx (75%) create mode 100644 packages/ui/src/components/pace-input/index.native.test.tsx rename apps/mobile/components/training-plan/create/inputs/PaceInput.tsx => packages/ui/src/components/pace-input/index.native.tsx (71%) create mode 100644 packages/ui/src/components/settings-group/index.native.test.tsx rename apps/mobile/components/settings/SettingsGroup.tsx => packages/ui/src/components/settings-group/index.native.tsx (64%) create mode 100644 packages/ui/src/components/switch/index.web.test.tsx create mode 100644 packages/ui/src/components/switch/index.web.tsx diff --git a/.opencode/specs/ui-core-app-centralization/design.md b/.opencode/specs/ui-core-app-centralization/design.md new file mode 100644 index 00000000..ca15d79a --- /dev/null +++ b/.opencode/specs/ui-core-app-centralization/design.md @@ -0,0 +1,135 @@ +# UI/Core App Centralization + +## Objective + +Define how GradientPeak should use `@repo/ui` and `@repo/core` so `apps/mobile` and `apps/web` share the same testable logic, keep app layers thin, and avoid feature drift. + +## Audit Snapshot + +- Branch: `audit/ui-core-shared-app-spec` +- `@repo/ui` usage is already strong in mobile (`238` files) and present but narrower in web (`25` files). +- `@repo/core` usage is strong in mobile (`103` files) but absent from `apps/web/src` (`0` files). +- The current gap is less about primitive adoption and more about missing shared domain adapters, shared feature-level composites, and package-boundary cleanup. + +## Current Findings + +### 1. `@repo/ui` is mostly a primitive package today + +- `packages/ui/src/components/index.ts` is a web-only barrel, so both apps rely on deep imports instead of a stable cross-platform entrypoint. +- `packages/ui/src/hooks/index.ts` and `packages/ui/src/registry/index.ts` are empty, so there is no shared home for higher-level component registration or helpers yet. +- The package exports many platform-aware primitives, but not many reusable composites. +- Web is missing some expected parity surfaces, including a web `Switch` implementation (`packages/ui/src/components/switch/` only has native files). + +### 2. `@repo/core` already contains substantial domain logic + +- `packages/core` already owns schemas, calculations, training-plan preview/projection logic, estimation, Bluetooth parsing, and reusable utilities. +- Mobile correctly consumes core in places like `apps/mobile/components/settings/ProfileSection.tsx` and `apps/mobile/lib/training-plan-form/localPreview.ts`. +- Web depends on `@repo/core` in `package.json` and transpiles it in `apps/web/next.config.ts`, but does not consume it from `apps/web/src`. + +### 3. Web still keeps local feature contracts that should be shared + +- `apps/web/src/app/(internal)/settings/page.tsx` defines a local `profileSchema` instead of using `profileQuickUpdateSchema` from `packages/core/schemas/form-schemas.ts`. +- `apps/web/src/app/(internal)/notifications/page.tsx` hand-parses `unknown` records with local getters instead of consuming typed notification/view-model helpers from core. +- `apps/web/src/app/(internal)/messages/page.tsx` and `apps/web/src/app/(internal)/coaching/page.tsx` still own shaping/parsing logic that should move into shared contracts or adapters. + +### 4. Mobile still owns several pure logic modules that should move to core + +- `apps/mobile/lib/goals/goalDraft.ts` is pure draft hydration and payload-building logic built on core types. +- `apps/mobile/lib/training-plan-form/input-parsers.ts` is reusable parsing and normalization logic. +- `apps/mobile/lib/training-plan-form/validation.ts` contains cross-field plan validation and derived rule logic. +- `apps/mobile/lib/profile/metricUnits.ts` and `apps/mobile/lib/utils/training-adjustments.ts` contain domain logic that should not stay app-local. + +### 5. Mobile still owns reusable feature-level UI that should move to ui + +- `apps/mobile/components/training-plan/create/inputs/BoundedNumberInput.tsx` +- `apps/mobile/components/training-plan/create/inputs/DurationInput.tsx` +- `apps/mobile/components/training-plan/create/inputs/PaceInput.tsx` +- `apps/mobile/components/training-plan/create/inputs/IntegerStepper.tsx` +- `apps/mobile/components/settings/SettingsGroup.tsx` +- `apps/mobile/components/shared/EmptyStateCard.tsx` +- `apps/mobile/components/shared/ErrorStateCard.tsx` +- `apps/mobile/components/ActivityPlan/MetricCard.tsx` + +### 6. Package boundaries need cleanup before wider reuse + +- `packages/core/README.md` says the package is database-independent with zero ORM/database dependencies. +- In reality, several runtime-exported core modules import Supabase types directly from `@repo/supabase`. +- That makes `@repo/core` less portable than its contract suggests and increases coupling between app logic and database-generated types. + +## Target Architecture + +### `@repo/ui` + +`@repo/ui` should own reusable presentation primitives and platform-aware composites. + +Keep in `@repo/ui`: +- primitives like button/card/input/tabs/avatar +- shared field wrappers and parsed input controls +- reusable display shells such as empty/error/settings/metric cards +- platform-specific implementations behind one export path + +Do not keep in `@repo/ui`: +- business rules +- request/response shaping +- Supabase types +- tRPC wiring +- route/navigation behavior + +### `@repo/core` + +`@repo/core` should own pure schemas, domain rules, parsers, view-model adapters, and formatting helpers. + +Keep in `@repo/core`: +- zod schemas and app-facing contracts +- parsing and normalization helpers +- derived-state reducers and validation rules +- feature view-model adapters for notifications, messaging, coaching, goals, plans, and activity summaries +- unit conversion and formatting helpers that are not UI-framework-specific + +Do not keep in `@repo/core`: +- React/React Native code +- Supabase clients or framework bindings +- app router/navigation code +- browser/native runtime side effects + +### Apps + +`apps/mobile` and `apps/web` should primarily compose shared packages. + +Apps should own only: +- route structure +- data fetching and mutation wiring +- local runtime integrations (Expo, browser APIs, device services) +- screen-specific orchestration and temporary UI state + +## Recommended Shared Surfaces + +### First-wave `@repo/core` additions + +- `goals/draft` helpers extracted from `apps/mobile/lib/goals/goalDraft.ts` +- `forms/parsers` helpers extracted from `apps/mobile/lib/training-plan-form/input-parsers.ts` +- training-plan form validation/adapters extracted from `apps/mobile/lib/training-plan-form/validation.ts` +- shared notification, messaging, and coaching view-model adapters for web pages currently parsing `unknown` +- shared profile/account update contracts so web and mobile use the same form models + +### First-wave `@repo/ui` additions + +- parsed numeric/duration/pace field components based on the mobile training-plan inputs +- shared `SettingsGroup`, `EmptyStateCard`, `ErrorStateCard`, and `MetricCard` composites where props can be made app-agnostic +- a web `Switch` implementation so settings forms do not fall back to ad hoc controls +- optional reusable web composites such as `data-table` and `avatar-stack` once they are generalized + +## Design Rules For Migration + +- Extract pure logic before extracting UI wrappers. +- Prefer shared contracts in core before adding new app features. +- Move generic composites into ui only after prop shapes are app-agnostic. +- Leave app-specific routing, mutation hooks, and platform runtime code in the apps. +- Add tests at the package that owns the behavior: core for logic, ui for shared rendering, apps for wiring. + +## Success Criteria + +- Web consumes `@repo/core` directly for profile, notification, messaging, coaching, and future training-plan flows. +- Mobile deletes app-local pure helpers that now live in core. +- Shared field/composite UI moves out of app folders into `@repo/ui` with platform-specific exports where needed. +- Package boundaries become clearer: `@repo/core` is genuinely framework-agnostic and database-light, and apps mostly orchestrate rather than implement business rules. diff --git a/.opencode/specs/ui-core-app-centralization/plan.md b/.opencode/specs/ui-core-app-centralization/plan.md new file mode 100644 index 00000000..709e4267 --- /dev/null +++ b/.opencode/specs/ui-core-app-centralization/plan.md @@ -0,0 +1,73 @@ +# Plan + +## Phase 0 - Boundary Cleanup + +Goal: make shared packages safe to depend on broadly. + +1. Normalize `@repo/core` boundaries so exported modules do not rely on `@repo/supabase` types where domain-owned types or adapter inputs would be better. +2. Decide whether `@repo/ui` keeps deep-import consumption as the standard or adds a clearer cross-platform barrel strategy. +3. Add missing shared-platform primitives required for adoption, starting with web `Switch`. + +Exit criteria: +- shared package contracts match their stated purpose +- web and mobile can adopt new shared modules without package-boundary confusion + +## Phase 1 - Extract Pure Mobile Logic Into `@repo/core` + +Goal: move business rules out of mobile app folders. + +1. Extract goal draft creation/hydration from `apps/mobile/lib/goals/goalDraft.ts`. +2. Extract reusable parsing helpers from `apps/mobile/lib/training-plan-form/input-parsers.ts`. +3. Extract training-plan validation and related adapter logic from `apps/mobile/lib/training-plan-form/validation.ts`. +4. Evaluate `metricUnits`, training-adjustment helpers, and recorder plan validation for follow-on extraction. + +Exit criteria: +- mobile imports these behaviors from `@repo/core` +- new shared functions have focused package-level tests + +## Phase 2 - Promote Shared Feature UI Into `@repo/ui` + +Goal: move reusable app composites out of app folders. + +1. Promote mobile parsed field components into a shared field/input layer. +2. Promote generic shell components such as settings groups, empty/error states, and metric cards. +3. Generalize reusable web composites like `apps/web/src/components/ui/data-table.tsx` only after prop contracts are stable. + +Exit criteria: +- both apps can compose the same shared composites where the behavior is shared +- app tests no longer own component behavior that belongs in `@repo/ui` + +## Phase 3 - Adopt Shared Contracts In Web + +Goal: make web a first-class consumer of `@repo/core`. + +1. Replace the local profile schema in `apps/web/src/app/(internal)/settings/page.tsx` with a shared core contract. +2. Move notification parsing from `apps/web/src/app/(internal)/notifications/page.tsx` into shared core adapters. +3. Move messaging/coaching row shaping into shared core adapters or contracts. +4. Prepare web feature parity work to reuse existing core logic for goals, training plans, activity summaries, and route formatting. + +Exit criteria: +- `apps/web/src` has direct `@repo/core` usage for feature logic +- web pages no longer parse `unknown` payloads ad hoc when a shared schema can own that responsibility + +## Phase 4 - Test Realignment + +Goal: put each test in the package that owns the behavior. + +1. Move pure logic tests from app folders into `packages/core`. +2. Move reusable composite rendering tests into `packages/ui`. +3. Keep app tests focused on route wiring, mutation flows, and platform-specific behavior. +4. Keep Playwright/Maestro coverage focused on integration rather than basic business-rule validation. + +Exit criteria: +- shared logic is covered by fast package-level tests +- app-level test suites shrink toward orchestration and integration coverage + +## Recommended Execution Order + +1. Boundary cleanup +2. Core parser/validation extraction +3. Web settings adoption of shared profile contract +4. Shared notification/message/coaching adapters +5. Shared field/composite UI promotion +6. Broader feature parity work on top of the new shared foundation diff --git a/.opencode/specs/ui-core-app-centralization/tasks.md b/.opencode/specs/ui-core-app-centralization/tasks.md new file mode 100644 index 00000000..890badf4 --- /dev/null +++ b/.opencode/specs/ui-core-app-centralization/tasks.md @@ -0,0 +1,22 @@ +# Tasks + +## Completed + +- Added a web `Switch` in `@repo/ui` and adopted it from `apps/web/src/app/(internal)/settings/page.tsx`. +- Extracted goal draft helpers from `apps/mobile/lib/goals/goalDraft.ts` into `packages/core/goals/draft.ts` and left the mobile file as a compatibility re-export. +- Extracted parsing helpers from `apps/mobile/lib/training-plan-form/input-parsers.ts` into `packages/core/forms/input-parsers.ts` and reused them from `packages/core/plan/trainingPlanPreview.ts`. +- Extracted training-plan validation/adapters from `apps/mobile/lib/training-plan-form/validation.ts` into `packages/core/plan/formValidation.ts` and left the mobile file as a compatibility re-export. +- Replaced the local schema in `apps/web/src/app/(internal)/settings/page.tsx` with a shared profile update contract derived from `profileQuickUpdateSchema`. +- Added shared notification adapters in `packages/core/notifications/index.ts` and adopted them from `apps/web/src/app/(internal)/notifications/page.tsx`, `apps/web/src/components/notifications-button.tsx`, and `apps/mobile/app/(internal)/(standard)/notifications/index.tsx`. +- Added package-level tests for the new core helpers and the new web `Switch`. +- Centralized mobile consumers on `@repo/ui` for parsed inputs and shared shell components, adopted shared `Alert`/`ToggleGroup` primitives in key mobile flows, and removed the app-local proxy wrappers those consumers previously depended on. +- Expanded mobile adoption of existing `@repo/ui` components by replacing more hand-rolled empty/error/auth/progress/comment-input states with shared `EmptyStateCard`, `ErrorStateCard`, `Alert`, `Progress`, and `Textarea` components. +- Continued the mobile centralization pass by replacing additional warning/divider/progress layouts with shared `Alert`, `Separator`, and `Progress` primitives and by adopting `RadioGroup` for training-plan schedule anchor selection. +- Migrated `GoalEditorModal` choice-group UI to shared `RadioGroup` and `ToggleGroup` primitives so mobile goal editing now relies more directly on `@repo/ui` selection controls. + +## Open + +- Define the `@repo/core` boundary policy for Supabase-derived types versus package-owned domain types and clean up remaining legacy schema imports. +- Promote reusable parsed field components from mobile into `packages/ui`. +- Promote generic shell components that are shared by both apps into `packages/ui`. +- Continue re-homing app-owned tests so package-level behavior lives primarily in `packages/core` and `packages/ui`. diff --git a/apps/mobile/app/(external)/onboarding.tsx b/apps/mobile/app/(external)/onboarding.tsx index 6eb9701c..b8d18e5e 100644 --- a/apps/mobile/app/(external)/onboarding.tsx +++ b/apps/mobile/app/(external)/onboarding.tsx @@ -1,3 +1,4 @@ +import { BoundedNumberInput } from "@repo/ui/components/bounded-number-input"; import { Button } from "@repo/ui/components/button"; import { Card, @@ -8,33 +9,23 @@ import { } from "@repo/ui/components/card"; import { Icon } from "@repo/ui/components/icon"; import { Label } from "@repo/ui/components/label"; -import { PaceSecondsField } from "@/components/profile/PaceSecondsField"; -import { WeightInputField } from "@/components/profile/WeightInputField"; -import { BoundedNumberInput } from "@/components/training-plan/create/inputs/BoundedNumberInput"; -import { DateField } from "@/components/training-plan/create/inputs/DateField"; import { Text } from "@repo/ui/components/text"; -import { - estimateFtpFromWeight, - estimateMaxHrFromDob, -} from "@/lib/profile/metricUnits"; -import { trpc } from "@/lib/trpc"; import { router } from "expo-router"; import { ArrowLeft, ArrowRight, Check } from "lucide-react-native"; import { useState } from "react"; import { Alert, ScrollView, View } from "react-native"; +import { PaceSecondsField } from "@/components/profile/PaceSecondsField"; +import { WeightInputField } from "@/components/profile/WeightInputField"; +import { DateField } from "@/components/training-plan/create/inputs/DateField"; +import { estimateFtpFromWeight, estimateMaxHrFromDob } from "@/lib/profile/metricUnits"; +import { trpc } from "@/lib/trpc"; interface OnboardingData { dob: string | null; weight_kg: number | null; weight_unit: "kg" | "lbs"; gender: "male" | "female" | "other" | null; - primary_sport: - | "cycling" - | "running" - | "swimming" - | "triathlon" - | "other" - | null; + primary_sport: "cycling" | "running" | "swimming" | "triathlon" | "other" | null; max_hr: number | null; resting_hr: number | null; lthr: number | null; @@ -126,21 +117,15 @@ export default function OnboardingScreen() { }); } - Alert.alert( - "Welcome to GradientPeak!", - "Your profile has been set up successfully.", - [ - { - text: "Get Started", - onPress: () => router.replace("/(internal)/(tabs)/home" as any), - }, - ], - ); + Alert.alert("Welcome to GradientPeak!", "Your profile has been set up successfully.", [ + { + text: "Get Started", + onPress: () => router.replace("/(internal)/(tabs)/home" as any), + }, + ]); } catch (error) { console.error("[Onboarding] Failed to save profile:", error); - Alert.alert("Error", "Failed to save your profile. Please try again.", [ - { text: "OK" }, - ]); + Alert.alert("Error", "Failed to save your profile. Please try again.", [{ text: "OK" }]); } }; @@ -196,21 +181,13 @@ export default function OnboardingScreen() { {currentStep === 1 ? ( - - ) : null} - {currentStep === 2 ? ( - + ) : null} + {currentStep === 2 ? : null} {currentStep === 3 ? ( ) : null} - {currentStep === 4 ? ( - - ) : null} + {currentStep === 4 ? : null} {/* Help Text */} - After updating your password, you will be automatically signed - in + After updating your password, you will be automatically signed in diff --git a/apps/mobile/app/(external)/sign-in.tsx b/apps/mobile/app/(external)/sign-in.tsx index 4596f87e..7631559b 100644 --- a/apps/mobile/app/(external)/sign-in.tsx +++ b/apps/mobile/app/(external)/sign-in.tsx @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { Alert, AlertDescription } from "@repo/ui/components/alert"; import { Button } from "@repo/ui/components/button"; import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/components/card"; import { @@ -12,6 +13,7 @@ import { import { Input } from "@repo/ui/components/input"; import { Text } from "@repo/ui/components/text"; import { useRouter } from "expo-router"; +import { AlertCircle } from "lucide-react-native"; import React from "react"; import { useForm } from "react-hook-form"; import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; @@ -190,14 +192,11 @@ export default function SignInScreen() { {/* Root Error */} {form.formState.errors.root && ( - - + + {form.formState.errors.root.message} - - + + )} diff --git a/apps/mobile/app/(external)/sign-up.tsx b/apps/mobile/app/(external)/sign-up.tsx index 5712e864..34b6144d 100644 --- a/apps/mobile/app/(external)/sign-up.tsx +++ b/apps/mobile/app/(external)/sign-up.tsx @@ -1,19 +1,7 @@ -import { useRouter } from "expo-router"; -import React from "react"; -import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; - import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - +import { Alert, AlertDescription } from "@repo/ui/components/alert"; import { Button } from "@repo/ui/components/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@repo/ui/components/card"; -import { ServerUrlOverride } from "@/components/auth/ServerUrlOverride"; +import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/components/card"; import { Form, FormControl, @@ -24,12 +12,15 @@ import { } from "@repo/ui/components/form"; import { Input } from "@repo/ui/components/input"; import { Text } from "@repo/ui/components/text"; +import { useRouter } from "expo-router"; +import { AlertCircle } from "lucide-react-native"; +import React from "react"; +import { useForm } from "react-hook-form"; +import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; +import { z } from "zod"; +import { ServerUrlOverride } from "@/components/auth/ServerUrlOverride"; import { useAuth } from "@/lib/hooks/useAuth"; -import { - getHostedApiUrl, - setServerUrlOverride, - useServerConfig, -} from "@/lib/server-config"; +import { getHostedApiUrl, setServerUrlOverride, useServerConfig } from "@/lib/server-config"; import { useAuthStore } from "@/lib/stores/auth-store"; import { supabase } from "@/lib/supabase/client"; @@ -64,8 +55,7 @@ export default function SignUpScreen() { const router = useRouter(); const { loading: authLoading } = useAuth(); const [isSubmitting, setIsSubmitting] = React.useState(false); - const [isServerConfigExpanded, setIsServerConfigExpanded] = - React.useState(false); + const [isServerConfigExpanded, setIsServerConfigExpanded] = React.useState(false); const serverConfig = useServerConfig(); const [serverUrlInput, setServerUrlInput] = React.useState( serverConfig.overrideUrl ?? serverConfig.apiUrl, @@ -256,17 +246,11 @@ export default function SignUpScreen() { {/* Root Error */} {form.formState.errors.root && ( - - + + {form.formState.errors.root.message} - - + + )} @@ -280,18 +264,14 @@ export default function SignUpScreen() { testID="sign-up-button" className="w-full" > - - {isLoading ? "Creating Account..." : "Create Account"} - + {isLoading ? "Creating Account..." : "Create Account"} - setIsServerConfigExpanded((currentValue) => !currentValue) - } + onToggle={() => setIsServerConfigExpanded((currentValue) => !currentValue)} onChange={setServerUrlInput} /> @@ -309,13 +289,8 @@ export default function SignUpScreen() { {/* Terms */} - - By creating an account, you agree to our{"\n"}Terms of Service - and Privacy Policy + + By creating an account, you agree to our{"\n"}Terms of Service and Privacy Policy diff --git a/apps/mobile/app/(external)/verify.tsx b/apps/mobile/app/(external)/verify.tsx index d2d7c574..be30c234 100644 --- a/apps/mobile/app/(external)/verify.tsx +++ b/apps/mobile/app/(external)/verify.tsx @@ -1,19 +1,7 @@ -import React, { useEffect, useState } from "react"; -import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { supabase } from "@/lib/supabase/client"; -import { useAuth } from "@/lib/hooks/useAuth"; +import { Alert, AlertDescription } from "@repo/ui/components/alert"; import { Button } from "@repo/ui/components/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@repo/ui/components/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/components/card"; import { Form, FormControl, @@ -24,12 +12,17 @@ import { } from "@repo/ui/components/form"; import { Input } from "@repo/ui/components/input"; import { Text } from "@repo/ui/components/text"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { AlertCircle } from "lucide-react-native"; +import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; +import { z } from "zod"; +import { useAuth } from "@/lib/hooks/useAuth"; +import { supabase } from "@/lib/supabase/client"; const verifySchema = z.object({ - token: z - .string() - .length(6, "Code must be 6 digits") - .regex(/^\d+$/, "Must be numbers only"), + token: z.string().length(6, "Code must be 6 digits").regex(/^\d+$/, "Must be numbers only"), }); type VerifyFields = z.infer; @@ -65,9 +58,7 @@ export default function VerifyScreen() { } = await supabase.auth.getUser(); if (user && user.email_confirmed_at) { - console.log( - "✅ Email verified via external link, refreshing session...", - ); + console.log("✅ Email verified via external link, refreshing session..."); // Refresh session to update the auth store // This will trigger the useEffect above via isEmailVerified await supabase.auth.refreshSession(); @@ -180,11 +171,11 @@ export default function VerifyScreen() { /> {form.formState.errors.root && ( - - + + {form.formState.errors.root.message} - - + + )} {resendMessage && ( {resendMessage} diff --git a/apps/mobile/app/(internal)/(standard)/__tests__/activity-plan-detail-scheduling.test.tsx b/apps/mobile/app/(internal)/(standard)/__tests__/activity-plan-detail-scheduling.test.tsx index 72227a3f..d53f45e4 100644 --- a/apps/mobile/app/(internal)/(standard)/__tests__/activity-plan-detail-scheduling.test.tsx +++ b/apps/mobile/app/(internal)/(standard)/__tests__/activity-plan-detail-scheduling.test.tsx @@ -3,9 +3,8 @@ import TestRenderer, { act } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; vi.mock("@tanstack/react-query", async () => { - const actual = await vi.importActual( - "@tanstack/react-query", - ); + const actual = + await vi.importActual("@tanstack/react-query"); return { ...actual, @@ -13,8 +12,7 @@ vi.mock("@tanstack/react-query", async () => { }; }); -const loadActivityPlanDetail = async () => - (await import("../activity-plan-detail")).default; +const loadActivityPlanDetail = async () => (await import("../activity-plan-detail")).default; const { alertMock, @@ -108,6 +106,10 @@ vi.mock("@repo/ui/components/text", () => ({ Text: createHost("Text"), })); +vi.mock("@repo/ui/components/textarea", () => ({ + Textarea: createHost("Textarea"), +})); + vi.mock("@/lib/hooks/useAuth", () => ({ useAuth: () => ({ profile: { id: "profile-1" } }), })); @@ -243,8 +245,7 @@ describe("activity plan detail scheduling", () => { const scheduleButton = renderer.root.findAll( (node) => - (node.type as any) === "Button" && - getTextContent(node.props.children).includes("Schedule"), + (node.type as any) === "Button" && getTextContent(node.props.children).includes("Schedule"), )[0]; await act(async () => { @@ -279,8 +280,7 @@ describe("activity plan detail scheduling", () => { const scheduleButton = renderer.root.findAll( (node) => - (node.type as any) === "Button" && - getTextContent(node.props.children).includes("Schedule"), + (node.type as any) === "Button" && getTextContent(node.props.children).includes("Schedule"), )[0]; await act(async () => { @@ -320,8 +320,7 @@ describe("activity plan detail scheduling", () => { const scheduleButton = renderer.root.findAll( (node) => - (node.type as any) === "Button" && - getTextContent(node.props.children).includes("Schedule"), + (node.type as any) === "Button" && getTextContent(node.props.children).includes("Schedule"), )[0]; await act(async () => { @@ -366,8 +365,7 @@ describe("activity plan detail scheduling", () => { const duplicateButton = renderer.root.findAll( (node) => - (node.type as any) === "Button" && - getTextContent(node.props.children) === "Duplicate", + (node.type as any) === "Button" && getTextContent(node.props.children) === "Duplicate", )[0]; await act(async () => { diff --git a/apps/mobile/app/(internal)/(standard)/__tests__/scheduled-activities-list.test.tsx b/apps/mobile/app/(internal)/(standard)/__tests__/scheduled-activities-list.test.tsx index dcede972..ae55c37c 100644 --- a/apps/mobile/app/(internal)/(standard)/__tests__/scheduled-activities-list.test.tsx +++ b/apps/mobile/app/(internal)/(standard)/__tests__/scheduled-activities-list.test.tsx @@ -35,10 +35,13 @@ vi.mock("@/components/plan/calendar/ActivityList", () => ({ })); vi.mock("@/components/shared", () => ({ - EmptyStateCard: createHost("EmptyStateCard"), ListSkeleton: createHost("ListSkeleton"), })); +vi.mock("@repo/ui/components/empty-state-card", () => ({ + EmptyStateCard: createHost("EmptyStateCard"), +})); + vi.mock("@repo/ui/components/icon", () => ({ Icon: createHost("Icon"), })); diff --git a/apps/mobile/app/(internal)/(standard)/__tests__/training-plan-deeplink.test.tsx b/apps/mobile/app/(internal)/(standard)/__tests__/training-plan-deeplink.test.tsx index 0eea600c..6e11854d 100644 --- a/apps/mobile/app/(internal)/(standard)/__tests__/training-plan-deeplink.test.tsx +++ b/apps/mobile/app/(internal)/(standard)/__tests__/training-plan-deeplink.test.tsx @@ -4,9 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import { ROUTES } from "@/lib/constants/routes"; vi.mock("@tanstack/react-query", async () => { - const actual = await vi.importActual( - "@tanstack/react-query", - ); + const actual = + await vi.importActual("@tanstack/react-query"); return { ...actual, @@ -14,8 +13,7 @@ vi.mock("@tanstack/react-query", async () => { }; }); -const loadTrainingPlanOverview = async () => - (await import("../training-plan-detail")).default; +const loadTrainingPlanOverview = async () => (await import("../training-plan-detail")).default; const { alertMock, @@ -46,15 +44,11 @@ const { projection: { at_goal_date: {} }, adherence_summary: { interpretation: "Adherence interpretation from timeline summary.", - contributors: [ - { detail: "Adherence contributor detail from timeline summary." }, - ], + contributors: [{ detail: "Adherence contributor detail from timeline summary." }], }, readiness_summary: { interpretation: "Readiness interpretation from timeline summary.", - contributors: [ - { detail: "Readiness contributor detail from timeline summary." }, - ], + contributors: [{ detail: "Readiness contributor detail from timeline summary." }], }, } as any, }, @@ -213,13 +207,7 @@ vi.mock("@/components/plan/PlanCapabilityMiniChart", () => ({ PlanCapabilityMiniChart: createHost("PlanCapabilityMiniChart"), })); vi.mock("@/components/shared/DetailChartModal", () => ({ - DetailChartModal: ({ - children, - visible, - onClose, - title, - defaultDateRange = "30d", - }: any) => { + DetailChartModal: ({ children, visible, onClose, title, defaultDateRange = "30d" }: any) => { const [range, setRange] = React.useState(defaultDateRange); if (!visible) { @@ -276,6 +264,10 @@ vi.mock("@/components/training-plan/create/inputs/DateField", () => ({ vi.mock("@repo/ui/components/input", () => ({ Input: createHost("Input"), })); +vi.mock("@repo/ui/components/radio-group", () => ({ + RadioGroup: createHost("RadioGroup"), + RadioGroupItem: createHost("RadioGroupItem"), +})); vi.mock("@repo/ui/components/switch", () => ({ Switch: createHost("Switch"), })); @@ -324,10 +316,7 @@ const getNodeText = (children: any): string => { return ""; }; -const hasTextContaining = ( - renderer: TestRenderer.ReactTestRenderer, - text: string, -) => +const hasTextContaining = (renderer: TestRenderer.ReactTestRenderer, text: string) => renderer.root.findAll((node: any) => { if (node.type !== "Text") { return false; @@ -336,28 +325,16 @@ const hasTextContaining = ( return getNodeText(node.props?.children).includes(text); }).length > 0; -const findTouchableByText = ( - renderer: TestRenderer.ReactTestRenderer, - text: string, -) => +const findTouchableByText = (renderer: TestRenderer.ReactTestRenderer, text: string) => renderer.root.find((node: any) => { - if ( - node.type !== "TouchableOpacity" || - typeof node.props.onPress !== "function" - ) { + if (node.type !== "TouchableOpacity" || typeof node.props.onPress !== "function") { return false; } - return ( - node.findAll((child: any) => getNodeText(child.props?.children) === text) - .length > 0 - ); + return node.findAll((child: any) => getNodeText(child.props?.children) === text).length > 0; }); -const findButtonByText = ( - renderer: TestRenderer.ReactTestRenderer, - text: string, -) => +const findButtonByText = (renderer: TestRenderer.ReactTestRenderer, text: string) => renderer.root.find((node: any) => { if (node.type !== "Button" || typeof node.props.onPress !== "function") { return false; @@ -366,15 +343,9 @@ const findButtonByText = ( return getNodeText(node.props?.children) === text; }); -const findInsightCardTouchable = ( - renderer: TestRenderer.ReactTestRenderer, - chartType: string, -) => +const findInsightCardTouchable = (renderer: TestRenderer.ReactTestRenderer, chartType: string) => renderer.root.find((node: any) => { - if ( - node.type !== "TouchableOpacity" || - typeof node.props.onPress !== "function" - ) { + if (node.type !== "TouchableOpacity" || typeof node.props.onPress !== "function") { return false; } @@ -383,8 +354,7 @@ const findInsightCardTouchable = ( const getModalTimelineLength = (renderer: TestRenderer.ReactTestRenderer) => { const timelineCharts = renderer.root.findAll( - (node: any) => - node.type === "PlanVsActualChart" && Array.isArray(node.props.timeline), + (node: any) => node.type === "PlanVsActualChart" && Array.isArray(node.props.timeline), ); if (timelineCharts.length === 0) { @@ -414,15 +384,11 @@ describe("TrainingPlanOverview deep-link routing", () => { projection: { at_goal_date: {} }, adherence_summary: { interpretation: "Adherence interpretation from timeline summary.", - contributors: [ - { detail: "Adherence contributor detail from timeline summary." }, - ], + contributors: [{ detail: "Adherence contributor detail from timeline summary." }], }, readiness_summary: { interpretation: "Readiness interpretation from timeline summary.", - contributors: [ - { detail: "Readiness contributor detail from timeline summary." }, - ], + contributors: [{ detail: "Readiness contributor detail from timeline summary." }], }, } as any; Object.keys(localSearchParamsMock).forEach((key) => { @@ -432,9 +398,7 @@ describe("TrainingPlanOverview deep-link routing", () => { it("redirects to create when no selected plan id exists", () => { resetTestState(); - let TrainingPlanOverview: Awaited< - ReturnType - >; + let TrainingPlanOverview: Awaited>; return loadTrainingPlanOverview().then((Component) => { TrainingPlanOverview = Component; @@ -442,9 +406,7 @@ describe("TrainingPlanOverview deep-link routing", () => { TestRenderer.create(); }); - expect(replaceMock).toHaveBeenCalledWith( - ROUTES.PLAN.TRAINING_PLAN.CREATE, - ); + expect(replaceMock).toHaveBeenCalledWith(ROUTES.PLAN.TRAINING_PLAN.CREATE); }); }); @@ -458,9 +420,7 @@ describe("TrainingPlanOverview deep-link routing", () => { renderer = TestRenderer.create(); }); - expect(replaceMock).not.toHaveBeenCalledWith( - ROUTES.PLAN.TRAINING_PLAN.CREATE, - ); + expect(replaceMock).not.toHaveBeenCalledWith(ROUTES.PLAN.TRAINING_PLAN.CREATE); expect(hasTextContaining(renderer, "No Training Plan")).toBe(true); expect(pushMock).not.toHaveBeenCalled(); }); @@ -571,14 +531,10 @@ describe("TrainingPlanOverview deep-link routing", () => { renderer = TestRenderer.create(); }); - expect( - hasTextContaining(renderer, "How should this schedule line up?"), - ).toBe(true); + expect(hasTextContaining(renderer, "How should this schedule line up?")).toBe(true); expect(hasTextContaining(renderer, "Start On")).toBe(true); expect(hasTextContaining(renderer, "Finish By")).toBe(true); - expect( - renderer.root.findAll((node: any) => node.type === "DateField"), - ).toHaveLength(1); + expect(renderer.root.findAll((node: any) => node.type === "DateField")).toHaveLength(1); expect(hasTextContaining(renderer, "Target Date (Optional)")).toBe(false); expect(hasTextContaining(renderer, "Start Date")).toBe(false); }); @@ -606,9 +562,7 @@ describe("TrainingPlanOverview deep-link routing", () => { finishByButton.props.onPress(); }); - const anchorField = renderer.root.find( - (node: any) => node.type === "DateField", - ); + const anchorField = renderer.root.find((node: any) => node.type === "DateField"); await act(async () => { anchorField.props.onChange("2026-04-30"); }); @@ -687,9 +641,7 @@ describe("TrainingPlanOverview deep-link routing", () => { activityButton.props.onPress(); }); - expect(pushMock).toHaveBeenCalledWith( - ROUTES.PLAN.ACTIVITY_DETAIL("activity-99"), - ); + expect(pushMock).toHaveBeenCalledWith(ROUTES.PLAN.ACTIVITY_DETAIL("activity-99")); }); it("does not show focus banner for unknown nextStep", async () => { @@ -737,8 +689,7 @@ describe("TrainingPlanOverview deep-link routing", () => { const duplicateButton = renderer.root.find( (node: any) => - node.type === "Button" && - getNodeText(node.props?.children) === "Make Editable Copy", + node.type === "Button" && getNodeText(node.props?.children) === "Make Editable Copy", ); await act(async () => { diff --git a/apps/mobile/app/(internal)/(standard)/__tests__/training-plans-list-screen.test.tsx b/apps/mobile/app/(internal)/(standard)/__tests__/training-plans-list-screen.test.tsx index c492e305..c8cea885 100644 --- a/apps/mobile/app/(internal)/(standard)/__tests__/training-plans-list-screen.test.tsx +++ b/apps/mobile/app/(internal)/(standard)/__tests__/training-plans-list-screen.test.tsx @@ -1,7 +1,7 @@ -import { ROUTES } from "@/lib/constants/routes"; import React from "react"; import TestRenderer, { act } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; +import { ROUTES } from "@/lib/constants/routes"; import TrainingPlansListScreenWithBoundary from "../training-plans-list"; const { pushMock, plansState } = vi.hoisted(() => ({ @@ -52,6 +52,10 @@ vi.mock("@repo/ui/components/card", () => ({ CardContent: createHost("CardContent"), })); +vi.mock("@repo/ui/components/empty-state-card", () => ({ + EmptyStateCard: createHost("EmptyStateCard"), +})); + vi.mock("@repo/ui/components/icon", () => ({ Icon: createHost("Icon"), })); @@ -90,9 +94,7 @@ describe("training plans list screen", () => { renderer = TestRenderer.create(); }); - const buttons = renderer.root.findAll( - (node: any) => node.type === "Button", - ); + const buttons = renderer.root.findAll((node: any) => node.type === "Button"); const createButton = buttons.find((node: any) => { const textNode = node.findAll((child: any) => child.type === "Text")[0]; return textNode?.props?.children === "Create Training Plan"; @@ -107,17 +109,13 @@ describe("training plans list screen", () => { expect(pushMock).toHaveBeenCalledWith(ROUTES.PLAN.TRAINING_PLAN.CREATE); const planCardPressables = renderer.root.findAll( - (node: any) => - node.type === "TouchableOpacity" && - typeof node.props.onPress === "function", + (node: any) => node.type === "TouchableOpacity" && typeof node.props.onPress === "function", ); act(() => { planCardPressables[0].props.onPress(); }); - expect(pushMock).toHaveBeenCalledWith( - ROUTES.PLAN.TRAINING_PLAN.DETAIL("plan-1"), - ); + expect(pushMock).toHaveBeenCalledWith(ROUTES.PLAN.TRAINING_PLAN.DETAIL("plan-1")); }); }); diff --git a/apps/mobile/app/(internal)/(standard)/__tests__/training-preferences-preview.test.tsx b/apps/mobile/app/(internal)/(standard)/__tests__/training-preferences-preview.test.tsx index 88f7a29f..5c5f4fe3 100644 --- a/apps/mobile/app/(internal)/(standard)/__tests__/training-preferences-preview.test.tsx +++ b/apps/mobile/app/(internal)/(standard)/__tests__/training-preferences-preview.test.tsx @@ -81,7 +81,7 @@ vi.mock("@/components/charts/PlanVsActualChart", () => ({ PlanVsActualChart: createHost("PlanVsActualChart"), })); -vi.mock("@/components/training-plan/create/inputs/IntegerStepper", () => ({ +vi.mock("@repo/ui/components/integer-stepper", () => ({ IntegerStepper: createHost("IntegerStepper"), })); @@ -229,19 +229,15 @@ describe("training preferences projection preview", () => { renderer = TestRenderer.create(); }); - const initialChart = renderer.root.findAll( - (node: any) => node.type === "PlanVsActualChart", - )[0]; + const initialChart = renderer.root.findAll((node: any) => node.type === "PlanVsActualChart")[0]; const initialLastCtl = - initialChart.props.projectedData[ - initialChart.props.projectedData.length - 1 - ].ctl; + initialChart.props.projectedData[initialChart.props.projectedData.length - 1].ctl; const behaviorTab = renderer.root.findAll( (node: any) => node.type === "Pressable" && - node.findAll((child: any) => child.type === "Text")[0]?.props - ?.children === "Training style", + node.findAll((child: any) => child.type === "Text")[0]?.props?.children === + "Training style", )[0]; act(() => { @@ -250,21 +246,16 @@ describe("training preferences projection preview", () => { const aggressivenessSlider = renderer.root.findAll( (node: any) => - node.type === "PercentSliderInput" && - node.props.id === "preferences-progression-pace", + node.type === "PercentSliderInput" && node.props.id === "preferences-progression-pace", )[0]; act(() => { aggressivenessSlider.props.onChange(80); }); - const updatedChart = renderer.root.findAll( - (node: any) => node.type === "PlanVsActualChart", - )[0]; + const updatedChart = renderer.root.findAll((node: any) => node.type === "PlanVsActualChart")[0]; const updatedLastCtl = - updatedChart.props.projectedData[ - updatedChart.props.projectedData.length - 1 - ].ctl; + updatedChart.props.projectedData[updatedChart.props.projectedData.length - 1].ctl; expect(updatedLastCtl).not.toEqual(initialLastCtl); }); @@ -282,24 +273,14 @@ describe("training preferences projection preview", () => { .findAll((node: any) => node.type === "Text") .map((node: any) => { const value = node.props.children; - return typeof value === "string" - ? value - : Array.isArray(value) - ? value.join("") - : ""; + return typeof value === "string" ? value : Array.isArray(value) ? value.join("") : ""; }); + expect(textValues.some((value: string) => value.includes("Preview unavailable"))).toBe(true); expect( - textValues.some((value: string) => value.includes("Preview unavailable")), - ).toBe(true); - expect( - textValues.some((value: string) => - value.includes("Start or activate a training plan"), - ), + textValues.some((value: string) => value.includes("Start or activate a training plan")), ).toBe(true); - expect( - renderer.root.findAll((node: any) => node.type === "PlanVsActualChart"), - ).toHaveLength(0); + expect(renderer.root.findAll((node: any) => node.type === "PlanVsActualChart")).toHaveLength(0); }); it("shows a baseline-curve message when projection data is missing", () => { @@ -322,23 +303,15 @@ describe("training preferences projection preview", () => { .findAll((node: any) => node.type === "Text") .map((node: any) => { const value = node.props.children; - return typeof value === "string" - ? value - : Array.isArray(value) - ? value.join("") - : ""; + return typeof value === "string" ? value : Array.isArray(value) ? value.join("") : ""; }); - expect( - textValues.some((value: string) => - value.includes("Baseline curve not ready"), - ), - ).toBe(true); - expect( - textValues.some((value: string) => - value.includes("baseline-vs-draft comparison"), - ), - ).toBe(true); + expect(textValues.some((value: string) => value.includes("Baseline curve not ready"))).toBe( + true, + ); + expect(textValues.some((value: string) => value.includes("baseline-vs-draft comparison"))).toBe( + true, + ); }); it("blocks saving when schedule limits conflict", () => { @@ -349,9 +322,7 @@ describe("training preferences projection preview", () => { }); const minSessionsStepper = renderer.root.findAll( - (node: any) => - node.type === "IntegerStepper" && - node.props.id === "preferences-min-sessions", + (node: any) => node.type === "IntegerStepper" && node.props.id === "preferences-min-sessions", )[0]; act(() => { @@ -369,11 +340,7 @@ describe("training preferences projection preview", () => { .findAll((node: any) => node.type === "Text") .map((node: any) => { const value = node.props.children; - return typeof value === "string" - ? value - : Array.isArray(value) - ? value.join("") - : ""; + return typeof value === "string" ? value : Array.isArray(value) ? value.join("") : ""; }); expect(saveButton?.props.disabled).toBe(true); @@ -395,8 +362,7 @@ describe("training preferences projection preview", () => { const goalStrategyTab = renderer.root.findAll( (node: any) => node.type === "Pressable" && - node.findAll((child: any) => child.type === "Text")[0]?.props - ?.children === "Goal strategy", + node.findAll((child: any) => child.type === "Text")[0]?.props?.children === "Goal strategy", )[0]; act(() => { @@ -405,8 +371,7 @@ describe("training preferences projection preview", () => { const surplusSlider = renderer.root.findAll( (node: any) => - node.type === "PercentSliderInput" && - node.props.id === "preferences-target-surplus", + node.type === "PercentSliderInput" && node.props.id === "preferences-target-surplus", )[0]; act(() => { diff --git a/apps/mobile/app/(internal)/(standard)/__tests__/user-detail-screen.test.tsx b/apps/mobile/app/(internal)/(standard)/__tests__/user-detail-screen.test.tsx index d62d73c0..dcaef405 100644 --- a/apps/mobile/app/(internal)/(standard)/__tests__/user-detail-screen.test.tsx +++ b/apps/mobile/app/(internal)/(standard)/__tests__/user-detail-screen.test.tsx @@ -4,43 +4,39 @@ import { describe, expect, it, vi } from "vitest"; import { ROUTES } from "@/lib/constants/routes"; import UserDetailScreenWithErrorBoundary from "../user/[userId]"; -const { - localSearchParamsMock, - pushMock, - replaceMock, - authState, - profileQueryState, -} = vi.hoisted(() => ({ - localSearchParamsMock: { userId: "11111111-1111-4111-8111-111111111111" }, - pushMock: vi.fn(), - replaceMock: vi.fn(), - authState: { - user: { id: "11111111-1111-4111-8111-111111111111", email: "own@test.com" }, - profile: { - id: "11111111-1111-4111-8111-111111111111", - username: "Owner", - avatar_url: null, - dob: "1990-01-01", - gender: null, - preferred_units: "metric", - language: "en", - bio: "Own profile", +const { localSearchParamsMock, pushMock, replaceMock, authState, profileQueryState } = vi.hoisted( + () => ({ + localSearchParamsMock: { userId: "11111111-1111-4111-8111-111111111111" }, + pushMock: vi.fn(), + replaceMock: vi.fn(), + authState: { + user: { id: "11111111-1111-4111-8111-111111111111", email: "own@test.com" }, + profile: { + id: "11111111-1111-4111-8111-111111111111", + username: "Owner", + avatar_url: null, + dob: "1990-01-01", + gender: null, + preferred_units: "metric", + language: "en", + bio: "Own profile", + }, }, - }, - profileQueryState: { - data: { - id: "11111111-1111-4111-8111-111111111111", - username: "Owner", - avatar_url: null, - bio: "Own profile", - gender: null, - preferred_units: "metric", - language: "en", - } as any, - isLoading: false, - error: null as any, - }, -})); + profileQueryState: { + data: { + id: "11111111-1111-4111-8111-111111111111", + username: "Owner", + avatar_url: null, + bio: "Own profile", + gender: null, + preferred_units: "metric", + language: "en", + } as any, + isLoading: false, + error: null as any, + }, + }), +); function createHost(type: string) { return function MockComponent(props: any) { @@ -64,7 +60,7 @@ vi.mock("@/components/ErrorBoundary", () => ({ ScreenErrorFallback: createHost("ScreenErrorFallback"), })); -vi.mock("@/components/settings", () => ({ +vi.mock("@repo/ui/components/settings-group", () => ({ SettingsGroup: createHost("SettingsGroup"), SettingItem: createHost("SettingItem"), })); diff --git a/apps/mobile/app/(internal)/(standard)/activities-list.tsx b/apps/mobile/app/(internal)/(standard)/activities-list.tsx index 4e50f9fd..91c969b4 100644 --- a/apps/mobile/app/(internal)/(standard)/activities-list.tsx +++ b/apps/mobile/app/(internal)/(standard)/activities-list.tsx @@ -1,21 +1,17 @@ -import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary"; -import { EmptyStateCard, ListSkeleton } from "@/components/shared"; +import type { PublicActivityCategory } from "@repo/supabase"; import { Button } from "@repo/ui/components/button"; import { Card, CardContent } from "@repo/ui/components/card"; +import { EmptyStateCard } from "@repo/ui/components/empty-state-card"; import { Icon } from "@repo/ui/components/icon"; import { Text } from "@repo/ui/components/text"; -import { trpc } from "@/lib/trpc"; -import type { PublicActivityCategory } from "@repo/supabase"; import { format } from "date-fns"; import { useRouter } from "expo-router"; import { Activity, ChevronRight } from "lucide-react-native"; import React, { useState } from "react"; -import { - RefreshControl, - ScrollView, - TouchableOpacity, - View, -} from "react-native"; +import { RefreshControl, ScrollView, TouchableOpacity, View } from "react-native"; +import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary"; +import { ListSkeleton } from "@/components/shared"; +import { trpc } from "@/lib/trpc"; type SortBy = "date" | "distance" | "duration" | "tss"; @@ -48,8 +44,7 @@ function formatDistance(meters: number): string { function ActivitiesScreen() { const router = useRouter(); const [refreshing, setRefreshing] = useState(false); - const [selectedType, setSelectedType] = - useState("bike"); + const [selectedType, setSelectedType] = useState("bike"); const [sortBy, setSortBy] = useState("date"); const [page, setPage] = useState(0); const limit = 20; @@ -139,9 +134,7 @@ function ActivitiesScreen() { > {type.icon} {type.label} @@ -164,14 +157,11 @@ function ActivitiesScreen() { - } + refreshControl={} onScroll={({ nativeEvent }) => { const { layoutMeasurement, contentOffset, contentSize } = nativeEvent; const isCloseToBottom = - layoutMeasurement.height + contentOffset.y >= - contentSize.height - 100; + layoutMeasurement.height + contentOffset.y >= contentSize.height - 100; if (isCloseToBottom && hasMore && !isLoading) { handleLoadMore(); } @@ -203,33 +193,23 @@ function ActivitiesScreen() { {/* Header with avatar, username, metadata */} - - {getActivityIcon(activity.type)} - + {getActivityIcon(activity.type)} {/* Username (or user info if available) */} - - You - + You {/* Metadata Row: Date + Device */} - {format( - new Date(activity.started_at), - "MMM d, yyyy • h:mm a", - )} + {format(new Date(activity.started_at), "MMM d, yyyy • h:mm a")} {activity.device_manufacturer && ( <> + - • - - - {activity.device_manufacturer}{" "} - {activity.device_product || ""} + {activity.device_manufacturer} {activity.device_product || ""} )} @@ -237,11 +217,7 @@ function ActivitiesScreen() { {/* Chevron */} - + {/* Activity Name */} @@ -251,10 +227,7 @@ function ActivitiesScreen() { {/* Notes (if any) */} {activity.notes && ( - + {activity.notes} )} @@ -263,18 +236,14 @@ function ActivitiesScreen() { {activity.distance_meters > 0 && ( - - Distance - + Distance {formatDistance(activity.distance_meters)} )} - - Duration - + Duration {formatDuration(activity.duration_seconds)} @@ -282,9 +251,7 @@ function ActivitiesScreen() { {activity.training_stress_score !== null && activity.training_stress_score !== undefined && ( - - TSS - + TSS {Math.round(activity.training_stress_score)} @@ -292,9 +259,7 @@ function ActivitiesScreen() { )} {activity.avg_power && ( - - Avg Power - + Avg Power {Math.round(activity.avg_power)}W @@ -302,9 +267,7 @@ function ActivitiesScreen() { )} {activity.avg_heart_rate && ( - - Avg HR - + Avg HR {Math.round(activity.avg_heart_rate)} bpm @@ -320,9 +283,7 @@ function ActivitiesScreen() { {hasMore && ( {isLoading ? ( - - Loading more... - + Loading more... ) : ( @@ -624,9 +577,7 @@ export default function ActivityPlanDetailPage() { > - {removeScheduleMutation.isPending - ? "Removing..." - : "Remove Schedule"} + {removeScheduleMutation.isPending ? "Removing..." : "Remove Schedule"} @@ -641,17 +592,11 @@ export default function ActivityPlanDetailPage() { {likesCount > 0 ? likesCount : "Like"} @@ -659,14 +604,8 @@ export default function ActivityPlanDetailPage() { {(commentsData?.total ?? 0) > 0 && ( <> · - - - {commentsData?.total} - + + {commentsData?.total} )} @@ -680,9 +619,7 @@ export default function ActivityPlanDetailPage() { > - {duplicatePlanMutation.isPending - ? "Duplicating..." - : "Duplicate"} + {duplicatePlanMutation.isPending ? "Duplicating..." : "Duplicate"} @@ -759,9 +696,7 @@ export default function ActivityPlanDetailPage() { {new Date(comment.created_at).toLocaleDateString()} - - {comment.content} - + {comment.content} ))} @@ -769,13 +704,11 @@ export default function ActivityPlanDetailPage() { {/* Add Comment Input */} - ) : ( @@ -1163,15 +1032,9 @@ export default function TrainingPlanOverview() { disabled={duplicatePlanMutation.isPending} className="flex-1" > - + - {duplicatePlanMutation.isPending - ? "Duplicating..." - : "Make Editable Copy"} + {duplicatePlanMutation.isPending ? "Duplicating..." : "Make Editable Copy"} )} @@ -1187,64 +1050,71 @@ export default function TrainingPlanOverview() { Schedule this plan - Choose one anchor for this schedule. You can either place - week 1 on a date or finish the whole plan by a date. + Choose one anchor for this schedule. You can either place week 1 on a date or + finish the whole plan by a date. - - How should this schedule line up? - - - - handleSelectScheduleAnchorMode("start") + How should this schedule line up? + { + if (nextValue === "start" || nextValue === "finish") { + handleSelectScheduleAnchorMode(nextValue); } + }} + > + handleSelectScheduleAnchorMode("start")} className={`rounded-lg border px-3 py-3 ${scheduleAnchorMode === "start" ? "border-primary bg-primary/5" : "border-border bg-background"}`} activeOpacity={0.8} > - - Start On - - - Put week 1 on a specific date. - + + + + + Start On + + + Put week 1 on a specific date. + + + - handleSelectScheduleAnchorMode("finish") - } + onPress={() => handleSelectScheduleAnchorMode("finish")} className={`rounded-lg border px-3 py-3 ${scheduleAnchorMode === "finish" ? "border-primary bg-primary/5" : "border-border bg-background"}`} activeOpacity={0.8} > - - Finish By - - - Back-schedule the plan so the final session lands by - a specific date. - + + + + + Finish By + + + Back-schedule the plan so the final session lands by a specific + date. + + + - + - setTemplateAnchorDate(nextDate ?? "") - } + onChange={(nextDate) => setTemplateAnchorDate(nextDate ?? "")} placeholder={scheduleAnchorContent.fieldPlaceholder} helperText={scheduleAnchorContent.helperText} clearable @@ -1255,9 +1125,7 @@ export default function TrainingPlanOverview() { @@ -1289,26 +1155,22 @@ export default function TrainingPlanOverview() { {(plan.structure as any).target_weekly_tss_min} -{" "} - {(plan.structure as any).target_weekly_tss_max} weekly - TSS + {(plan.structure as any).target_weekly_tss_max} weekly TSS - {(plan.structure as any).target_activities_per_week}{" "} - sessions/week + {(plan.structure as any).target_activities_per_week} sessions/week - {(plan.structure as any).max_consecutive_days} max - consecutive days + {(plan.structure as any).max_consecutive_days} max consecutive days - {(plan.structure as any).min_rest_days_per_week} rest - days/week + {(plan.structure as any).min_rest_days_per_week} rest days/week @@ -1316,31 +1178,17 @@ export default function TrainingPlanOverview() { <> - - Periodization - + Periodization - { - (plan.structure as any).periodization_template - .starting_ctl - }{" "} - →{" "} - { - (plan.structure as any).periodization_template - .target_ctl - }{" "} - CTL + {(plan.structure as any).periodization_template.starting_ctl} →{" "} + {(plan.structure as any).periodization_template.target_ctl} CTL - - Target Date - + Target Date {new Date( - ( - plan.structure as any - ).periodization_template.target_date, + (plan.structure as any).periodization_template.target_date, ).toLocaleDateString("en-US", { month: "short", day: "numeric", @@ -1353,10 +1201,7 @@ export default function TrainingPlanOverview() { {isOwnedByUser && ( <> - + Edit structure in composer @@ -1368,9 +1213,7 @@ export default function TrainingPlanOverview() { - - Microcycle weekly load (estimated) - + Microcycle weekly load (estimated) {weeklyLoadSummary.length === 0 ? ( Add linked activity plans to see estimated weekly TSS. @@ -1378,16 +1221,10 @@ export default function TrainingPlanOverview() { ) : ( {weeklyLoadSummary.map((week) => { - const widthPercent = Math.max( - 6, - (week.estimatedTss / maxWeeklyLoad) * 100, - ); + const widthPercent = Math.max(6, (week.estimatedTss / maxWeeklyLoad) * 100); return ( - + Week {week.microcycle} @@ -1411,9 +1248,7 @@ export default function TrainingPlanOverview() { - - Linked activity plan structures - + Linked activity plan structures {isLoadingLinkedPlans ? ( Loading linked activity plans... @@ -1433,18 +1268,9 @@ export default function TrainingPlanOverview() { {linkedPlan.name} - {( - linkedPlan.activity_category ?? "other" - ).toUpperCase()}{" "} - ·{" "} - {Math.round( - readFiniteNumber(linkedPlan.estimated_tss), - )}{" "} - TSS ·{" "} - {Math.round( - readFiniteNumber(linkedPlan.estimated_duration), - )}{" "} - min + {(linkedPlan.activity_category ?? "other").toUpperCase()} ·{" "} + {Math.round(readFiniteNumber(linkedPlan.estimated_tss))} TSS ·{" "} + {Math.round(readFiniteNumber(linkedPlan.estimated_duration))} min {hasIntervals(linkedPlan.structure) @@ -1459,9 +1285,7 @@ export default function TrainingPlanOverview() { - - Sessions by microcycle and day - + Sessions by microcycle and day {groupedStructureSessions.length === 0 ? ( No structured sessions found in this template yet. @@ -1477,10 +1301,7 @@ export default function TrainingPlanOverview() { Week {microcycle.microcycle} - {microcycle.days.reduce( - (count, day) => count + day.sessions.length, - 0, - )}{" "} + {microcycle.days.reduce((count, day) => count + day.sessions.length, 0)}{" "} session {microcycle.days.reduce( (count, day) => count + day.sessions.length, @@ -1517,29 +1338,20 @@ export default function TrainingPlanOverview() { {session.activityPlanId - ? activityPlanNameById.get( - session.activityPlanId, - ) ?? "Linked activity plan" + ? (activityPlanNameById.get(session.activityPlanId) ?? + "Linked activity plan") : "No linked activity plan"} {isOwnedByUser ? ( - handleOpenActivityPickerForSession( - session, - ) - } - disabled={ - updatePlanStructureMutation.isPending - } + onPress={() => handleOpenActivityPickerForSession(session)} + disabled={updatePlanStructureMutation.isPending} className="rounded-full border border-border px-2 py-1" activeOpacity={0.8} > - {session.activityPlanId - ? "Change" - : "Add"} + {session.activityPlanId ? "Change" : "Add"} ) : null} @@ -1548,14 +1360,8 @@ export default function TrainingPlanOverview() { {session.activityPlanId ? ( - handleRemoveActivityFromSession( - session, - ) - } - disabled={ - updatePlanStructureMutation.isPending - } + onPress={() => handleRemoveActivityFromSession(session)} + disabled={updatePlanStructureMutation.isPending} className="flex-row items-center gap-1 rounded-full border border-destructive/30 px-2 py-1" activeOpacity={0.8} > @@ -1587,8 +1393,8 @@ export default function TrainingPlanOverview() { - Deleting this training plan will permanently remove its - structure and all associated planned activities. + Deleting this training plan will permanently remove its structure and all + associated planned activities. @@ -1635,9 +1439,7 @@ export default function TrainingPlanOverview() { {isLoadingActivityPlans ? ( - - Loading activity plans... - + Loading activity plans... ) : activityPlanItems.length === 0 ? ( @@ -1661,9 +1463,7 @@ export default function TrainingPlanOverview() { {activityPlan.name} - - {activityPlan.id} - + {activityPlan.id} ))} @@ -1673,18 +1473,13 @@ export default function TrainingPlanOverview() { - - + Current plan already scheduled - You already have scheduled sessions from a training plan. Finish or - abandon that plan before scheduling another one. + You already have scheduled sessions from a training plan. Finish or abandon that plan + before scheduling another one. @@ -1714,9 +1506,7 @@ export default function TrainingPlanOverview() { diff --git a/apps/mobile/app/(internal)/(standard)/training-plans-list.tsx b/apps/mobile/app/(internal)/(standard)/training-plans-list.tsx index 976f12ef..3f1adc44 100644 --- a/apps/mobile/app/(internal)/(standard)/training-plans-list.tsx +++ b/apps/mobile/app/(internal)/(standard)/training-plans-list.tsx @@ -1,20 +1,16 @@ -import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary"; -import { ListSkeleton } from "@/components/shared"; import { Button } from "@repo/ui/components/button"; import { Card, CardContent } from "@repo/ui/components/card"; +import { EmptyStateCard } from "@repo/ui/components/empty-state-card"; import { Icon } from "@repo/ui/components/icon"; import { Text } from "@repo/ui/components/text"; -import { ROUTES } from "@/lib/constants/routes"; -import { trpc } from "@/lib/trpc"; import { useRouter } from "expo-router"; import { ChevronRight, Eye, EyeOff, Plus } from "lucide-react-native"; import React, { useMemo, useState } from "react"; -import { - RefreshControl, - ScrollView, - TouchableOpacity, - View, -} from "react-native"; +import { RefreshControl, ScrollView, TouchableOpacity, View } from "react-native"; +import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary"; +import { ListSkeleton } from "@/components/shared"; +import { ROUTES } from "@/lib/constants/routes"; +import { trpc } from "@/lib/trpc"; function TrainingPlansListScreen() { const router = useRouter(); @@ -53,28 +49,20 @@ function TrainingPlansListScreen() { - } + refreshControl={} > - {sortedPlans.length === 0 ? ( - - - - No training plans yet - - - Create your first plan to start scheduling structured training. - - - + router.push(ROUTES.PLAN.TRAINING_PLAN.CREATE as any)} + /> ) : ( sortedPlans.map((plan) => { const isPublic = plan.template_visibility === "public"; @@ -83,9 +71,7 @@ function TrainingPlansListScreen() { return ( - router.push(ROUTES.PLAN.TRAINING_PLAN.DETAIL(plan.id) as any) - } + onPress={() => router.push(ROUTES.PLAN.TRAINING_PLAN.DETAIL(plan.id) as any)} activeOpacity={0.8} > @@ -106,9 +92,7 @@ function TrainingPlansListScreen() { size={12} className="text-muted-foreground" /> - - {visibilityLabel} - + {visibilityLabel} @@ -116,11 +100,7 @@ function TrainingPlansListScreen() { Open to edit, apply, or delete. - + diff --git a/apps/mobile/app/(internal)/(standard)/training-preferences.tsx b/apps/mobile/app/(internal)/(standard)/training-preferences.tsx index 37b8ae35..faed5701 100644 --- a/apps/mobile/app/(internal)/(standard)/training-preferences.tsx +++ b/apps/mobile/app/(internal)/(standard)/training-preferences.tsx @@ -1,22 +1,17 @@ -import { PlanVsActualChart } from "@/components/charts/PlanVsActualChart"; -import { IntegerStepper } from "@/components/training-plan/create/inputs/IntegerStepper"; -import { PercentSliderInput } from "@/components/training-plan/create/inputs/PercentSliderInput"; +import type { AthleteTrainingSettings } from "@repo/core"; import { Button } from "@repo/ui/components/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@repo/ui/components/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/components/card"; +import { Input } from "@repo/ui/components/input"; +import { IntegerStepper } from "@repo/ui/components/integer-stepper"; import { Switch } from "@repo/ui/components/switch"; import { Text } from "@repo/ui/components/text"; -import { Input } from "@repo/ui/components/input"; +import React, { useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { PlanVsActualChart } from "@/components/charts/PlanVsActualChart"; +import { PercentSliderInput } from "@/components/training-plan/create/inputs/PercentSliderInput"; import { useProfileSettings } from "@/lib/hooks/useProfileSettings"; import { useTrainingPlanSnapshot } from "@/lib/hooks/useTrainingPlanSnapshot"; import { trpc } from "@/lib/trpc"; -import type { AthleteTrainingSettings } from "@repo/core"; -import React, { useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; type PreferencesTabKey = | "schedule" @@ -68,27 +63,19 @@ function deriveProjectionPreview( } const progressionFactor = (draft.training_style.progression_pace - 0.5) * 0.8; - const recoveryFactor = - (draft.recovery_preferences.recovery_priority - 0.5) * 0.6; + const recoveryFactor = (draft.recovery_preferences.recovery_priority - 0.5) * 0.6; const sessionRange = - (draft.dose_limits.max_sessions_per_week ?? 7) - - (draft.dose_limits.min_sessions_per_week ?? 0); + (draft.dose_limits.max_sessions_per_week ?? 7) - (draft.dose_limits.min_sessions_per_week ?? 0); const sessionFactor = (sessionRange - 4) / 20; const durationFactor = - ((draft.dose_limits.max_single_session_duration_minutes ?? 180) - 180) / - 420; + ((draft.dose_limits.max_single_session_duration_minutes ?? 180) - 180) / 420; const growthFactor = clamp( - 1 + - progressionFactor - - recoveryFactor + - sessionFactor * 0.2 + - durationFactor * 0.2, + 1 + progressionFactor - recoveryFactor + sessionFactor * 0.2 + durationFactor * 0.2, 0.6, 1.5, ); - const variabilityAmplitude = - (draft.training_style.week_pattern_preference - 0.5) * 0.35; + const variabilityAmplitude = (draft.training_style.week_pattern_preference - 0.5) * 0.35; const preview = [baseCurve[0]!]; @@ -115,9 +102,7 @@ export default function TrainingPreferencesScreen() { const activePlan = activePlanQuery.data; const settingsQuery = useProfileSettings(); const [activeTab, setActiveTab] = useState("schedule"); - const [draft, setDraft] = useState( - settingsQuery.settings, - ); + const [draft, setDraft] = useState(settingsQuery.settings); const snapshot = useTrainingPlanSnapshot({ planId: activePlan?.id, includeWeeklySummaries: false, @@ -159,16 +144,13 @@ export default function TrainingPreferencesScreen() { const scheduleValidation = useMemo(() => { const minSessions = draft.dose_limits.min_sessions_per_week ?? 0; const maxSessions = draft.dose_limits.max_sessions_per_week ?? 0; - const maxSingleSessionDuration = - draft.dose_limits.max_single_session_duration_minutes; + const maxSingleSessionDuration = draft.dose_limits.max_single_session_duration_minutes; const maxWeeklyDuration = draft.dose_limits.max_weekly_duration_minutes; const issues: string[] = []; if (minSessions > maxSessions) { - issues.push( - "Fewest sessions per week cannot be higher than most sessions per week.", - ); + issues.push("Fewest sessions per week cannot be higher than most sessions per week."); } if ( @@ -176,9 +158,7 @@ export default function TrainingPreferencesScreen() { typeof maxWeeklyDuration === "number" && maxSingleSessionDuration > maxWeeklyDuration ) { - issues.push( - "Weekly time budget must be at least as long as your longest workout.", - ); + issues.push("Weekly time budget must be at least as long as your longest workout."); } return { @@ -207,10 +187,7 @@ export default function TrainingPreferencesScreen() { }, [draft]); const goalMetrics = useMemo(() => { - if ( - !snapshot.idealCurveData?.targetCTL || - !snapshot.idealCurveData?.targetDate - ) { + if (!snapshot.idealCurveData?.targetCTL || !snapshot.idealCurveData?.targetDate) { return null; } @@ -238,11 +215,7 @@ export default function TrainingPreferencesScreen() { }, [idealFitnessCurve, previewIdealCurve]); const projectionPreviewState = useMemo(() => { - if ( - activePlanQuery.isLoading || - snapshot.loading.plan || - snapshot.loading.idealCurve - ) { + if (activePlanQuery.isLoading || snapshot.loading.plan || snapshot.loading.idealCurve) { return { tone: "loading" as const, title: "Loading projection preview", @@ -299,12 +272,8 @@ export default function TrainingPreferencesScreen() { const scheduleSnapshot = useMemo(() => { const minSessions = draft.dose_limits.min_sessions_per_week ?? 0; const maxSessions = draft.dose_limits.max_sessions_per_week ?? 0; - const weeklyBudget = formatMinutes( - draft.dose_limits.max_weekly_duration_minutes, - ); - const longestWorkout = formatMinutes( - draft.dose_limits.max_single_session_duration_minutes, - ); + const weeklyBudget = formatMinutes(draft.dose_limits.max_weekly_duration_minutes); + const longestWorkout = formatMinutes(draft.dose_limits.max_single_session_duration_minutes); return `${minSessions}-${maxSessions} sessions per week, ${weeklyBudget ?? "no weekly cap"}, longest workout ${longestWorkout ?? "not set"}.`; }, [draft.dose_limits]); @@ -324,9 +293,7 @@ export default function TrainingPreferencesScreen() { return ( - - Loading preferences... - + Loading preferences... ); } @@ -356,9 +323,7 @@ export default function TrainingPreferencesScreen() { {projectionPreviewState.title} - - {projectionPreviewState.body} - + {projectionPreviewState.body} )} @@ -370,9 +335,8 @@ export default function TrainingPreferencesScreen() { : projectionPreviewState.body} - Progression pace changes how fast training builds. Target surplus - is separate and only nudges scoring past your stated goal when the - model has enough support. + Progression pace changes how fast training builds. Target surplus is separate and only + nudges scoring past your stated goal when the model has enough support. @@ -409,9 +373,8 @@ export default function TrainingPreferencesScreen() { {activeTab === "schedule" ? ( <> - Set the weekly training floor, ceiling, and time budget the - planner should respect. Planner-only tuning stays out of this - profile view. + Set the weekly training floor, ceiling, and time budget the planner should + respect. Planner-only tuning stays out of this profile view. Current draft: {scheduleSnapshot} @@ -465,9 +428,7 @@ export default function TrainingPreferencesScreen() { - Training style is about progression and week feel, not bounded - upside beyond the goal target. + Training style is about progression and week feel, not bounded upside beyond the + goal target. - Goal strategy changes how closely the planner hugs the stated - target versus aiming for bounded upside when confidence - supports it. This stays separate from progression pace and - schedule limits. + Goal strategy changes how closely the planner hugs the stated target versus aiming + for bounded upside when confidence supports it. This stays separate from + progression pace and schedule limits. - Override your baseline fitness to unlock higher volume - training plans without historical data. This tells the system - your current CTL (fitness) and ATL (fatigue) so it doesn't cap - your plan due to low historical load. + Override your baseline fitness to unlock higher volume training plans without + historical data. This tells the system your current CTL (fitness) and ATL + (fatigue) so it doesn't cap your plan due to low historical load. @@ -738,18 +681,13 @@ export default function TrainingPreferencesScreen() { ...current, baseline_fitness: { is_enabled: checked, - override_ctl: - current.baseline_fitness?.override_ctl ?? 0, - override_atl: - current.baseline_fitness?.override_atl ?? 0, - override_date: - current.baseline_fitness?.override_date, + override_ctl: current.baseline_fitness?.override_ctl ?? 0, + override_atl: current.baseline_fitness?.override_atl ?? 0, + override_date: current.baseline_fitness?.override_date, max_weekly_tss_ramp_pct: - current.baseline_fitness?.max_weekly_tss_ramp_pct ?? - 10, + current.baseline_fitness?.max_weekly_tss_ramp_pct ?? 10, max_ctl_ramp_per_week: - current.baseline_fitness?.max_ctl_ramp_per_week ?? - 5, + current.baseline_fitness?.max_ctl_ramp_per_week ?? 5, }, })) } @@ -768,19 +706,14 @@ export default function TrainingPreferencesScreen() { setDraft((current) => ({ ...current, baseline_fitness: { - is_enabled: - current.baseline_fitness?.is_enabled ?? false, + is_enabled: current.baseline_fitness?.is_enabled ?? false, override_ctl: value, - override_atl: - current.baseline_fitness?.override_atl ?? 0, - override_date: - current.baseline_fitness?.override_date, + override_atl: current.baseline_fitness?.override_atl ?? 0, + override_date: current.baseline_fitness?.override_date, max_weekly_tss_ramp_pct: - current.baseline_fitness - ?.max_weekly_tss_ramp_pct ?? 10, + current.baseline_fitness?.max_weekly_tss_ramp_pct ?? 10, max_ctl_ramp_per_week: - current.baseline_fitness?.max_ctl_ramp_per_week ?? - 5, + current.baseline_fitness?.max_ctl_ramp_per_week ?? 5, }, })) } @@ -796,19 +729,14 @@ export default function TrainingPreferencesScreen() { setDraft((current) => ({ ...current, baseline_fitness: { - is_enabled: - current.baseline_fitness?.is_enabled ?? false, - override_ctl: - current.baseline_fitness?.override_ctl ?? 0, + is_enabled: current.baseline_fitness?.is_enabled ?? false, + override_ctl: current.baseline_fitness?.override_ctl ?? 0, override_atl: value, - override_date: - current.baseline_fitness?.override_date, + override_date: current.baseline_fitness?.override_date, max_weekly_tss_ramp_pct: - current.baseline_fitness - ?.max_weekly_tss_ramp_pct ?? 10, + current.baseline_fitness?.max_weekly_tss_ramp_pct ?? 10, max_ctl_ramp_per_week: - current.baseline_fitness?.max_ctl_ramp_per_week ?? - 5, + current.baseline_fitness?.max_ctl_ramp_per_week ?? 5, }, })) } @@ -829,19 +757,14 @@ export default function TrainingPreferencesScreen() { setDraft((current) => ({ ...current, baseline_fitness: { - is_enabled: - current.baseline_fitness?.is_enabled ?? false, - override_ctl: - current.baseline_fitness?.override_ctl ?? 0, - override_atl: - current.baseline_fitness?.override_atl ?? 0, + is_enabled: current.baseline_fitness?.is_enabled ?? false, + override_ctl: current.baseline_fitness?.override_ctl ?? 0, + override_atl: current.baseline_fitness?.override_atl ?? 0, override_date: value, max_weekly_tss_ramp_pct: - current.baseline_fitness - ?.max_weekly_tss_ramp_pct ?? 10, + current.baseline_fitness?.max_weekly_tss_ramp_pct ?? 10, max_ctl_ramp_per_week: - current.baseline_fitness - ?.max_ctl_ramp_per_week ?? 5, + current.baseline_fitness?.max_ctl_ramp_per_week ?? 5, }, })) } @@ -854,17 +777,14 @@ export default function TrainingPreferencesScreen() { Advanced: Ramp Rate Settings - Override the default weekly ramp caps. Higher values - allow faster training load progression but increase - injury risk. + Override the default weekly ramp caps. Higher values allow faster training + load progression but increase injury risk. ({ ...current, baseline_fitness: { - is_enabled: - current.baseline_fitness?.is_enabled ?? false, - override_ctl: - current.baseline_fitness?.override_ctl ?? 0, - override_atl: - current.baseline_fitness?.override_atl ?? 0, - override_date: - current.baseline_fitness?.override_date, + is_enabled: current.baseline_fitness?.is_enabled ?? false, + override_ctl: current.baseline_fitness?.override_ctl ?? 0, + override_atl: current.baseline_fitness?.override_atl ?? 0, + override_date: current.baseline_fitness?.override_date, max_weekly_tss_ramp_pct: value, max_ctl_ramp_per_week: - current.baseline_fitness?.max_ctl_ramp_per_week ?? - 5, + current.baseline_fitness?.max_ctl_ramp_per_week ?? 5, }, })) } @@ -899,42 +814,31 @@ export default function TrainingPreferencesScreen() { setDraft((current) => ({ ...current, baseline_fitness: { - is_enabled: - current.baseline_fitness?.is_enabled ?? false, - override_ctl: - current.baseline_fitness?.override_ctl ?? 0, - override_atl: - current.baseline_fitness?.override_atl ?? 0, - override_date: - current.baseline_fitness?.override_date, + is_enabled: current.baseline_fitness?.is_enabled ?? false, + override_ctl: current.baseline_fitness?.override_ctl ?? 0, + override_atl: current.baseline_fitness?.override_atl ?? 0, + override_date: current.baseline_fitness?.override_date, max_weekly_tss_ramp_pct: - current.baseline_fitness - ?.max_weekly_tss_ramp_pct ?? 10, + current.baseline_fitness?.max_weekly_tss_ramp_pct ?? 10, max_ctl_ramp_per_week: value, }, })) } /> - - Why Adjust Ramp Rates? - + Why Adjust Ramp Rates? - If your plan's readiness score feels too low, it may be - because the goal requires more training load than the - default ramp caps allow. Try increasing the Max Weekly - TSS Ramp % or Max CTL Ramp Per Week above to see if that - unlocks a higher readiness. Higher values allow faster - progression but increase injury risk. + If your plan's readiness score feels too low, it may be because the goal + requires more training load than the default ramp caps allow. Try increasing + the Max Weekly TSS Ramp % or Max CTL Ramp Per Week above to see if that + unlocks a higher readiness. Higher values allow faster progression but + increase injury risk. - - Example CTL Values - + Example CTL Values - Recreatonal: 30-50 | Intermediate: 50-80 | Advanced: - 80-120 | Elite: 120+ + Recreatonal: 30-50 | Intermediate: 50-80 | Advanced: 80-120 | Elite: 120+ diff --git a/apps/mobile/app/(internal)/(standard)/user/[userId].tsx b/apps/mobile/app/(internal)/(standard)/user/[userId].tsx index 19cd6e00..5d65d969 100644 --- a/apps/mobile/app/(internal)/(standard)/user/[userId].tsx +++ b/apps/mobile/app/(internal)/(standard)/user/[userId].tsx @@ -1,26 +1,20 @@ -import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary"; -import { SettingItem, SettingsGroup } from "@/components/settings"; import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar"; import { Button } from "@repo/ui/components/button"; import { Card, CardContent } from "@repo/ui/components/card"; import { Icon } from "@repo/ui/components/icon"; import { Input } from "@repo/ui/components/input"; +import { SettingItem, SettingsGroup } from "@repo/ui/components/settings-group"; import { Text } from "@repo/ui/components/text"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { Clock, Edit3, MessageCircle, UserMinus, UserPlus } from "lucide-react-native"; +import React, { useMemo, useState } from "react"; +import { Alert, ScrollView, View } from "react-native"; +import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary"; import { ROUTES } from "@/lib/constants/routes"; import { useAuth } from "@/lib/hooks/useAuth"; import { useAuthStore } from "@/lib/stores/auth-store"; import { useTheme } from "@/lib/stores/theme-store"; import { trpc } from "@/lib/trpc"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { - Edit3, - MessageCircle, - UserMinus, - UserPlus, - Clock, -} from "lucide-react-native"; -import React, { useMemo, useState } from "react"; -import { Alert, ScrollView, View } from "react-native"; function calculateAge(dob: string | null | undefined): number | null { if (!dob) return null; @@ -31,8 +25,7 @@ function calculateAge(dob: string | null | undefined): number | null { let years = today.getFullYear() - dobDate.getFullYear(); const monthDelta = today.getMonth() - dobDate.getMonth(); const hasBirthdayPassed = - monthDelta > 0 || - (monthDelta === 0 && today.getDate() >= dobDate.getDate()); + monthDelta > 0 || (monthDelta === 0 && today.getDate() >= dobDate.getDate()); if (!hasBirthdayPassed) years -= 1; return years; } @@ -64,12 +57,9 @@ function UserDetailScreen() { // For own profile, use auth profile but merge in counts from targetProfile return { ...profile, - followers_count: - targetProfile?.followers_count ?? profile.followers_count ?? 0, - following_count: - targetProfile?.following_count ?? profile.following_count ?? 0, - follow_status: - targetProfile?.follow_status ?? profile.follow_status ?? null, + followers_count: targetProfile?.followers_count ?? profile.followers_count ?? 0, + following_count: targetProfile?.following_count ?? profile.following_count ?? 0, + follow_status: targetProfile?.follow_status ?? profile.follow_status ?? null, }; } return targetProfile; @@ -101,17 +91,13 @@ function UserDetailScreen() { router.replace("/(external)/sign-in" as any); setTimeout(() => { - Alert.alert( - "Account Deleted", - "Your account has been successfully deleted.", - ); + Alert.alert("Account Deleted", "Your account has been successfully deleted."); }, 500); }, onError: (mutationError) => { Alert.alert( "Error", - mutationError.message || - "Failed to delete account. Please contact support.", + mutationError.message || "Failed to delete account. Please contact support.", ); }, }); @@ -131,20 +117,14 @@ function UserDetailScreen() { const updatePasswordMutation = trpc.auth.updatePassword.useMutation({ onSuccess: () => { - Alert.alert( - "Password Updated", - "Your password has been successfully changed.", - ); + Alert.alert("Password Updated", "Your password has been successfully changed."); setIsPasswordChangeVisible(false); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); }, onError: (mutationError) => { - Alert.alert( - "Error", - mutationError.message || "Failed to update password", - ); + Alert.alert("Error", mutationError.message || "Failed to update password"); }, }); @@ -184,8 +164,7 @@ function UserDetailScreen() { router.push(`/messages/${(data as any).id}` as any); } }, - onError: (err) => - Alert.alert("Error", err.message || "Failed to start message"), + onError: (err) => Alert.alert("Error", err.message || "Failed to start message"), }); const handleSignOut = () => { @@ -263,10 +242,7 @@ function UserDetailScreen() { return; } if (currentPassword === newPassword) { - Alert.alert( - "Error", - "New password must be different from current password", - ); + Alert.alert("Error", "New password must be different from current password"); return; } @@ -284,9 +260,7 @@ function UserDetailScreen() { if (isLoading) { return ( - - Loading profile... - + Loading profile... ); } @@ -299,9 +273,7 @@ function UserDetailScreen() { {isNotFound ? "Profile not found" : "Unable to load profile"} {!isNotFound && ( - - Please try again. - + Please try again. )} ); @@ -310,9 +282,7 @@ function UserDetailScreen() { if (!renderedProfile) { return ( - - Profile unavailable. - + Profile unavailable. ); } @@ -335,10 +305,7 @@ function UserDetailScreen() { - + {renderedProfile?.avatar_url ? ( {renderedProfile?.username?.charAt(0)?.toUpperCase() || - (isOwnProfile - ? user?.email?.charAt(0)?.toUpperCase() - : null) || + (isOwnProfile ? user?.email?.charAt(0)?.toUpperCase() : null) || "U"} @@ -360,26 +325,20 @@ function UserDetailScreen() { {renderedProfile?.username || "Unknown user"} {isOwnProfile && user?.email ? ( - - {user.email} - + {user.email} ) : null} {/* Followers/Following Counts - show for ALL profiles */} - router.push(`/followers?userId=${targetUserId}` as any) - } + onPress={() => router.push(`/followers?userId=${targetUserId}` as any)} > {renderedProfile?.followers_count ?? 0} followers - router.push(`/following?userId=${targetUserId}` as any) - } + onPress={() => router.push(`/following?userId=${targetUserId}` as any)} > {renderedProfile?.following_count ?? 0} following @@ -387,11 +346,7 @@ function UserDetailScreen() { {!isOwnProfile && renderedProfile?.follow_status === "pending" && ( - + Follow request pending @@ -415,9 +370,7 @@ function UserDetailScreen() { @@ -659,17 +581,13 @@ function UserDetailScreen() { description="Change your password" buttonLabel={isPasswordChangeVisible ? "Cancel" : "Change"} variant="outline" - onPress={() => - setIsPasswordChangeVisible(!isPasswordChangeVisible) - } + onPress={() => setIsPasswordChangeVisible(!isPasswordChangeVisible)} testID="change-password" /> {isPasswordChangeVisible && ( - - Change Your Password - + Change Your Password - @@ -734,9 +647,7 @@ function UserDetailScreen() { label="Dark Mode" description="Switch between light and dark themes" value={theme === "dark"} - onValueChange={(isChecked) => - setTheme(isChecked ? "dark" : "light") - } + onValueChange={(isChecked) => setTheme(isChecked ? "dark" : "light")} testID="dark-mode" /> @@ -750,9 +661,7 @@ function UserDetailScreen() { type="button" label="Sign Out" description="Sign out of your account" - buttonLabel={ - signOutMutation.isPending ? "Signing out..." : "Sign Out" - } + buttonLabel={signOutMutation.isPending ? "Signing out..." : "Sign Out"} variant="destructive" onPress={handleSignOut} disabled={signOutMutation.isPending} @@ -763,9 +672,7 @@ function UserDetailScreen() { type="button" label="Delete Account" description="Permanently delete your account and all data" - buttonLabel={ - deleteAccountMutation.isPending ? "Deleting..." : "Delete" - } + buttonLabel={deleteAccountMutation.isPending ? "Deleting..." : "Delete"} variant="destructive" onPress={handleDeleteAccount} disabled={deleteAccountMutation.isPending} @@ -789,9 +696,7 @@ function UserDetailScreen() { {typeof __DEV__ !== "undefined" && __DEV__ && ( - - User ID: {user?.id || "None"} - + User ID: {user?.id || "None"} Email: {user?.email || "None"} diff --git a/apps/mobile/app/(internal)/(tabs)/discover.tsx b/apps/mobile/app/(internal)/(tabs)/discover.tsx index f002ee8e..4ac70645 100644 --- a/apps/mobile/app/(internal)/(tabs)/discover.tsx +++ b/apps/mobile/app/(internal)/(tabs)/discover.tsx @@ -1,13 +1,9 @@ -import { AppHeader } from "@/components/shared"; -import { ActivityPlanCard } from "@/components/shared/ActivityPlanCard"; import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar"; import { Button } from "@repo/ui/components/button"; +import { EmptyStateCard } from "@repo/ui/components/empty-state-card"; import { Icon } from "@repo/ui/components/icon"; import { Input } from "@repo/ui/components/input"; import { Text } from "@repo/ui/components/text"; -import { ROUTES } from "@/lib/constants/routes"; -import { useAuth } from "@/lib/hooks/useAuth"; -import { trpc } from "@/lib/trpc"; import { useRouter } from "expo-router"; import { Activity, @@ -23,14 +19,12 @@ import { X, } from "lucide-react-native"; import React, { useEffect, useMemo, useState } from "react"; -import { - Dimensions, - FlatList, - Modal, - ScrollView, - TouchableOpacity, - View, -} from "react-native"; +import { Dimensions, FlatList, Modal, ScrollView, TouchableOpacity, View } from "react-native"; +import { AppHeader } from "@/components/shared"; +import { ActivityPlanCard } from "@/components/shared/ActivityPlanCard"; +import { ROUTES } from "@/lib/constants/routes"; +import { useAuth } from "@/lib/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; const TABS = [ { id: "activityPlans", label: "Activity Plans", icon: Activity }, @@ -145,9 +139,7 @@ export default function DiscoverPage() { }); const activityPlans = useMemo(() => { - return ( - activityPlansInfiniteQuery.data?.pages.flatMap((page) => page.items) || [] - ); + return activityPlansInfiniteQuery.data?.pages.flatMap((page) => page.items) || []; }, [activityPlansInfiniteQuery.data]); const routes = useMemo(() => { @@ -227,9 +219,7 @@ export default function DiscoverPage() { - - Searching: {`"${debouncedSearch}"`} - + Searching: {`"${debouncedSearch}"`} setSearchQuery("")}> Clear @@ -289,14 +277,8 @@ export default function DiscoverPage() { ); const renderEmptyState = (message: string) => ( - - - - {message} - - - Try adjusting your search - + + ); @@ -308,9 +290,7 @@ export default function DiscoverPage() { p.activity_category === category.id, - )} + activities={activityPlans.filter((p) => p.activity_category === category.id)} onViewAll={() => handleViewAll(category.id)} onTemplatePress={handleTemplatePress} /> @@ -368,10 +348,7 @@ export default function DiscoverPage() { contentContainerStyle={{ padding: 16, gap: 12 }} keyExtractor={(item) => item.id} renderItem={({ item }) => ( - handleTrainingPlanPress(item)} - /> + handleTrainingPlanPress(item)} /> )} ListEmptyComponent={renderEmptyState("No training plans found")} onRefresh={() => trainingPlansQuery.refetch()} @@ -394,9 +371,7 @@ export default function DiscoverPage() { data={routes} contentContainerStyle={{ padding: 16, gap: 12 }} keyExtractor={(item) => item.id} - renderItem={({ item }) => ( - handleRoutePress(item)} /> - )} + renderItem={({ item }) => handleRoutePress(item)} />} ListEmptyComponent={renderEmptyState("No routes found")} onRefresh={() => routesInfiniteQuery.refetch()} refreshing={routesInfiniteQuery.isRefetching} @@ -411,9 +386,7 @@ export default function DiscoverPage() { disabled={routesInfiniteQuery.isFetchingNextPage} > - {routesInfiniteQuery.isFetchingNextPage - ? "Loading more..." - : "Load More"} + {routesInfiniteQuery.isFetchingNextPage ? "Loading more..." : "Load More"} @@ -437,9 +410,7 @@ export default function DiscoverPage() { data={users} contentContainerStyle={{ padding: 16, gap: 12 }} keyExtractor={(item) => item.id} - renderItem={({ item }) => ( - handleUserPress(item)} /> - )} + renderItem={({ item }) => handleUserPress(item)} />} ListEmptyComponent={renderEmptyState("No users found")} onRefresh={() => usersQuery.refetch()} refreshing={usersQuery.isRefetching} @@ -479,12 +450,7 @@ interface CategoryRowProps { onTemplatePress: (template: any) => void; } -function CategoryRow({ - category, - activities, - onViewAll, - onTemplatePress, -}: CategoryRowProps) { +function CategoryRow({ category, activities, onViewAll, onTemplatePress }: CategoryRowProps) { if (activities.length === 0) return null; return ( @@ -527,18 +493,12 @@ interface TrainingPlanCardProps { function TrainingPlanCard({ template, onPress }: TrainingPlanCardProps) { return ( - + - - {template.name} - + {template.name} - {template.durationWeeks?.recommended || template.durationWeeks?.min}{" "} - weeks + {template.durationWeeks?.recommended || template.durationWeeks?.min} weeks @@ -550,9 +510,7 @@ function TrainingPlanCard({ template, onPress }: TrainingPlanCardProps) { {template.sport?.map((s: string) => ( - - {s} - + {s} ))} @@ -566,26 +524,16 @@ interface RouteCardProps { } function RouteCard({ route, onPress }: RouteCardProps) { - const distanceKm = route.total_distance - ? (route.total_distance / 1000).toFixed(1) - : "0"; + const distanceKm = route.total_distance ? (route.total_distance / 1000).toFixed(1) : "0"; const elevationM = route.total_ascent || 0; return ( - + - - {route.name} - + {route.name} {route.description && ( - + {route.description} )} @@ -630,9 +578,7 @@ function UserCard({ user, onPress }: UserCardProps) { - - {user.username} - + {user.username} {user.is_public ? "Public Profile" : "Private Profile"} diff --git a/apps/mobile/app/(internal)/record/plan.tsx b/apps/mobile/app/(internal)/record/plan.tsx index 282fbf49..eff7aeac 100644 --- a/apps/mobile/app/(internal)/record/plan.tsx +++ b/apps/mobile/app/(internal)/record/plan.tsx @@ -13,18 +13,19 @@ * - Recording continues in background */ +import type { PublicActivityCategory } from "@repo/supabase"; +import { EmptyStateCard } from "@repo/ui/components/empty-state-card"; import { Icon } from "@repo/ui/components/icon"; import { Input } from "@repo/ui/components/input"; import { Text } from "@repo/ui/components/text"; +import { router } from "expo-router"; +import { CalendarDays, Check, Search } from "lucide-react-native"; +import React, { useCallback, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import { useActivityStatus, usePlan } from "@/lib/hooks/useActivityRecorder"; import { useRecordingConfiguration } from "@/lib/hooks/useRecordingConfiguration"; import { useSharedActivityRecorder } from "@/lib/providers/ActivityRecorderProvider"; import { trpc } from "@/lib/trpc"; -import type { PublicActivityCategory } from "@repo/supabase"; -import { router } from "expo-router"; -import { Check, Search } from "lucide-react-native"; -import React, { useCallback, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; const CATEGORY_OPTIONS: { value: PublicActivityCategory | "all"; @@ -46,13 +47,10 @@ export default function PlanPickerPage() { // Search and filter state const [searchQuery, setSearchQuery] = useState(""); - const [categoryFilter, setCategoryFilter] = useState< - PublicActivityCategory | "all" - >("all"); + const [categoryFilter, setCategoryFilter] = useState("all"); // Fetch today's planned events - const { data: plannedActivities, isLoading } = - trpc.events.getToday.useQuery(); + const { data: plannedActivities, isLoading } = trpc.events.getToday.useQuery(); // Filter planned activities by search and category filter const filteredPlannedActivities = React.useMemo(() => { @@ -60,10 +58,7 @@ export default function PlanPickerPage() { return plannedActivities.filter((pa) => { // Category filter - if ( - categoryFilter !== "all" && - pa.activity_plan?.activity_category !== categoryFilter - ) { + if (categoryFilter !== "all" && pa.activity_plan?.activity_category !== categoryFilter) { return false; } @@ -133,11 +128,7 @@ export default function PlanPickerPage() { {/* Category Filter Dropdown */} - + {CATEGORY_OPTIONS.map((option) => ( {option.label} @@ -170,9 +158,7 @@ export default function PlanPickerPage() { {isLoading && ( - - Loading plans... - + Loading plans... )} @@ -196,9 +182,7 @@ export default function PlanPickerPage() { )} {/* Planned Activities List */} - {!isLoading && - filteredPlannedActivities && - filteredPlannedActivities.length > 0 ? ( + {!isLoading && filteredPlannedActivities && filteredPlannedActivities.length > 0 ? ( {filteredPlannedActivities.map((plannedActivity) => ( handlePlanPress( plannedActivity.id, - plannedActivity.activity_plan - ?.activity_category as PublicActivityCategory, + plannedActivity.activity_plan?.activity_category as PublicActivityCategory, ) } /> @@ -217,18 +200,20 @@ export default function PlanPickerPage() { ) : ( !isLoading && ( - - - {searchQuery || categoryFilter !== "all" + - - {searchQuery || categoryFilter !== "all" + : "No planned activities for today" + } + description={ + searchQuery || categoryFilter !== "all" ? "Try adjusting your search or filter" - : "Schedule activities from the Plan tab"} - - + : "Schedule activities from the Plan tab" + } + iconSize={32} + /> ) )} @@ -262,9 +247,7 @@ function PlannedActivityListItem({ const activityPlan = plannedActivity.activity_plan; // Format the scheduled time - const scheduledTime = new Date( - plannedActivity.scheduled_date, - ).toLocaleTimeString("en-US", { + const scheduledTime = new Date(plannedActivity.scheduled_date).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, @@ -282,9 +265,7 @@ function PlannedActivityListItem({ - - {scheduledTime} - + {scheduledTime} {activityPlan && ( <> @@ -294,19 +275,13 @@ function PlannedActivityListItem({ )} - - {activityPlan?.name || "Unnamed Activity"} - + {activityPlan?.name || "Unnamed Activity"} {activityPlan?.description && ( - - {activityPlan.description} - + {activityPlan.description} )} - {isSelected && ( - - )} + {isSelected && } ); diff --git a/apps/mobile/app/(internal)/record/route.tsx b/apps/mobile/app/(internal)/record/route.tsx index a358665b..032bd615 100644 --- a/apps/mobile/app/(internal)/record/route.tsx +++ b/apps/mobile/app/(internal)/record/route.tsx @@ -13,19 +13,18 @@ * - Recording continues in background */ +import type { PublicActivityCategory } from "@repo/supabase"; +import { EmptyStateCard } from "@repo/ui/components/empty-state-card"; import { Icon } from "@repo/ui/components/icon"; import { Input } from "@repo/ui/components/input"; import { Text } from "@repo/ui/components/text"; -import { useRecordingConfiguration } from "@/lib/hooks/useRecordingConfiguration"; -import { useSharedActivityRecorder } from "@/lib/providers/ActivityRecorderProvider"; -import { trpc } from "@/lib/trpc"; -import type { - PublicActivityCategory -} from "@repo/supabase"; import { router } from "expo-router"; -import { Check, Search } from "lucide-react-native"; +import { Check, Route, Search } from "lucide-react-native"; import React, { useCallback, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { useRecordingConfiguration } from "@/lib/hooks/useRecordingConfiguration"; +import { useSharedActivityRecorder } from "@/lib/providers/ActivityRecorderProvider"; +import { trpc } from "@/lib/trpc"; const CATEGORY_OPTIONS: { value: PublicActivityCategory | "all"; @@ -45,9 +44,7 @@ export default function RoutePickerPage() { // Search and filter state const [searchQuery, setSearchQuery] = useState(""); - const [categoryFilter, setCategoryFilter] = useState< - PublicActivityCategory | "all" - >("all"); + const [categoryFilter, setCategoryFilter] = useState("all"); // Fetch routes (no category filter in query since we filter client-side) const { data: routes, isLoading } = trpc.routes.list.useInfiniteQuery( @@ -82,10 +79,7 @@ export default function RoutePickerPage() { const filteredRoutes = React.useMemo(() => { return routesList.filter((route: any) => { // Category filter - if ( - categoryFilter !== "all" && - route.activity_category !== categoryFilter - ) { + if (categoryFilter !== "all" && route.activity_category !== categoryFilter) { return false; } @@ -127,11 +121,7 @@ export default function RoutePickerPage() { {/* Category Filter Dropdown */} - + {CATEGORY_OPTIONS.map((option) => ( {option.label} @@ -164,9 +151,7 @@ export default function RoutePickerPage() { {isLoading && ( - - Loading routes... - + Loading routes... )} @@ -178,9 +163,7 @@ export default function RoutePickerPage() { > - - Detach Current Route - + Detach Current Route Remove route from this workout @@ -203,18 +186,20 @@ export default function RoutePickerPage() { ) : ( !isLoading && ( - - - {searchQuery || categoryFilter !== "all" + - - {searchQuery || categoryFilter !== "all" + : "No routes available" + } + description={ + searchQuery || categoryFilter !== "all" ? "Try adjusting your search or filter" - : "Upload routes from the Library tab"} - - + : "Upload routes from the Library tab" + } + iconSize={32} + /> ) )} @@ -254,24 +239,18 @@ function RouteListItem({ route, isSelected, onPress }: RouteListItemProps) { {route.name} - - {distanceKm} km - + {distanceKm} km {route.activity_category} {route.description && ( - - {route.description} - + {route.description} )} - {isSelected && ( - - )} + {isSelected && } ); diff --git a/apps/mobile/app/(internal)/record/sensors.tsx b/apps/mobile/app/(internal)/record/sensors.tsx index 787575c9..af0705f8 100644 --- a/apps/mobile/app/(internal)/record/sensors.tsx +++ b/apps/mobile/app/(internal)/record/sensors.tsx @@ -1,41 +1,30 @@ -import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary"; import { Button } from "@repo/ui/components/button"; +import { EmptyStateCard } from "@repo/ui/components/empty-state-card"; import { Icon } from "@repo/ui/components/icon"; import { Text } from "@repo/ui/components/text"; -import { - useRecorderActions, - useSensors, -} from "@/lib/hooks/useActivityRecorder"; +import { Battery, Bluetooth, RefreshCw, Zap } from "lucide-react-native"; +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, ScrollView, View } from "react-native"; +import type { Device } from "react-native-ble-plx"; +import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary"; +import { useRecorderActions, useSensors } from "@/lib/hooks/useActivityRecorder"; import { useSharedActivityRecorder } from "@/lib/providers/ActivityRecorderProvider"; import { + type AllPermissionsStatus, checkAllPermissions, requestPermission, - type AllPermissionsStatus, } from "@/lib/services/permissions-check"; -import { Battery, Bluetooth, RefreshCw, Zap } from "lucide-react-native"; -import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, ScrollView, View } from "react-native"; -import type { Device } from "react-native-ble-plx"; function SensorsScreen() { const service = useSharedActivityRecorder(); const { sensors: connectedSensors } = useSensors(service); - const { - startScan, - stopScan, - subscribeScan, - connectDevice, - disconnectDevice, - } = useRecorderActions(service); - - const [permissions, setPermissions] = useState( - null, - ); + const { startScan, stopScan, subscribeScan, connectDevice, disconnectDevice } = + useRecorderActions(service); + + const [permissions, setPermissions] = useState(null); const [isScanning, setIsScanning] = useState(false); const [availableDevices, setAvailableDevices] = useState([]); - const [connectingDevices, setConnectingDevices] = useState>( - new Set(), - ); + const [connectingDevices, setConnectingDevices] = useState>(new Set()); const [isRequestingPermission, setIsRequestingPermission] = useState(false); const [bleState, setBleState] = useState("Unknown"); const [scanError, setScanError] = useState(null); @@ -48,9 +37,7 @@ function SensorsScreen() { setAvailableDevices((prev) => { // Only add if not already in list and not connected const isAlreadyAdded = prev.some((d) => d.id === device.id); - const isConnected = connectedSensors.some( - (sensor) => sensor.id === device.id, - ); + const isConnected = connectedSensors.some((sensor) => sensor.id === device.id); if (!isAlreadyAdded && !isConnected) { return [...prev, device]; @@ -65,9 +52,7 @@ function SensorsScreen() { // Remove devices from available list when they become connected useEffect(() => { setAvailableDevices((prev) => - prev.filter( - (device) => !connectedSensors.some((sensor) => sensor.id === device.id), - ), + prev.filter((device) => !connectedSensors.some((sensor) => sensor.id === device.id)), ); }, [connectedSensors]); @@ -256,11 +241,7 @@ function SensorsScreen() { ) : ( - + Scan for Sensors )} @@ -275,9 +256,7 @@ function SensorsScreen() { - {bluetoothCanAsk - ? "Bluetooth permission needed" - : "Enable Bluetooth in settings"} + {bluetoothCanAsk ? "Bluetooth permission needed" : "Enable Bluetooth in settings"} {bluetoothCanAsk && ( @@ -309,9 +288,7 @@ function SensorsScreen() { {/* BLE State Indicator (for debugging) */} {bleState !== "PoweredOn" && bleState !== "Unknown" && ( - - Bluetooth Status: {bleState} - + Bluetooth Status: {bleState} )} @@ -322,12 +299,7 @@ function SensorsScreen() { Connected ({connectedSensors.length}) - @@ -372,23 +344,18 @@ function SensorsScreen() { {sensor.isControllable && ( - - Control - + Control )} {/* Show current control mode if controllable */} {sensor.isControllable && (() => { - const controller = - service?.sensorsManager.getFTMSController(sensor.id); + const controller = service?.sensorsManager.getFTMSController(sensor.id); const mode = controller?.getCurrentMode(); if (mode) { return ( - - Mode: {mode} - + Mode: {mode} ); } return null; @@ -401,9 +368,7 @@ function SensorsScreen() { onPress={() => handleDisconnectDevice(sensor.id)} className="h-8" > - - Disconnect - + Disconnect ))} @@ -417,30 +382,19 @@ function SensorsScreen() { {!bluetoothGranted ? ( - - - - Grant permission to scan - - + ) : availableDevices.length === 0 && !isScanning ? ( - - - - No devices found - - - {isScanning ? "Searching..." : "Tap scan to search"} - - + ) : ( availableDevices.map((device) => { const isConnecting = connectingDevices.has(device.id); @@ -451,25 +405,15 @@ function SensorsScreen() { > - - {device.name || "Unknown Device"} - + {device.name || "Unknown Device"} {/* Check if this is a connected sensor with controllable flag */} {(() => { - const connectedSensor = connectedSensors.find( - (s) => s.id === device.id, - ); + const connectedSensor = connectedSensors.find((s) => s.id === device.id); if (connectedSensor?.isControllable) { return ( - - - Control - + + Control ); } @@ -477,10 +421,7 @@ function SensorsScreen() { })()} {device.id && ( - + {device.id} )} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index c6c2aaca..ea4f3d25 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -18,6 +18,7 @@ import { initializeServerConfig, useServerConfig } from "@/lib/server-config"; import { StreamBuffer } from "@/lib/services/ActivityRecorder/StreamBuffer"; import { GarminFitEncoder } from "@/lib/services/fit/GarminFitEncoder"; import { initSentry } from "@/lib/services/sentry"; +import { useAuthStore } from "@/lib/stores/auth-store"; import { useTheme } from "@/lib/stores/theme-store"; // Initialize Sentry error tracking for production @@ -58,9 +59,19 @@ export function ErrorBoundary({ error, retry }: { error: Error; retry: () => voi function AppContent() { console.log("AppContent loaded"); - const { userStatus, onboardingStatus, isAuthenticated, isFullyLoaded, user } = useAuth(); + const { + userStatus, + onboardingStatus, + isAuthenticated, + isFullyLoaded, + user, + profileLoading, + profileError, + refreshProfile, + } = useAuth(); const { theme, resolvedTheme, isLoaded: isThemeLoaded } = useTheme(); const segments = useSegments(); + const clearSession = useAuthStore((state) => state.clearSession); const inInternalGroup = segments[0] === "(internal)"; const inExternalGroup = segments[0] === "(external)"; @@ -95,6 +106,21 @@ function AppContent() { } // Verified but not onboarded: onboarding is mandatory before internal app. + if (onboardingStatus === null) { + if (profileLoading) { + return { type: "loading" as const }; + } + + if (profileError) { + return { type: "profile-error" as const, message: profileError.message }; + } + + return { + type: "redirect" as const, + to: "/(internal)/(standard)/onboarding" as const, + }; + } + if (onboardingStatus !== true) { return isOnboardingScreen ? { type: "allow" as const } @@ -138,6 +164,23 @@ function AppContent() { ); } + if (guardDecision.type === "profile-error") { + return ( + + + We couldn't finish loading your account + + {guardDecision.message} + + + + ); + } + if (guardDecision.type === "redirect") { if (guardDecision.to === "/(external)/verify") { return ; diff --git a/apps/mobile/components/ActivityListModal.tsx b/apps/mobile/components/ActivityListModal.tsx index 43018d96..3760c876 100644 --- a/apps/mobile/components/ActivityListModal.tsx +++ b/apps/mobile/components/ActivityListModal.tsx @@ -1,16 +1,9 @@ // gradientpeak/apps/mobile/app/(internal)/(tabs)/trends/components/ActivityListModal.tsx +import { EmptyStateCard } from "@repo/ui/components/empty-state-card"; import { Icon } from "@repo/ui/components/icon"; import { Text } from "@repo/ui/components/text"; -import { trpc } from "@/lib/trpc"; -import { - Activity, - Calendar, - CheckCircle2, - Clock, - X, - Zap, -} from "lucide-react-native"; +import { Activity, Calendar, CheckCircle2, Clock, X, Zap } from "lucide-react-native"; import { ActivityIndicator, InteractionManager, @@ -20,6 +13,7 @@ import { TouchableOpacity, View, } from "react-native"; +import { trpc } from "@/lib/trpc"; interface ActivityListModalProps { visible: boolean; @@ -149,14 +143,9 @@ export function ActivityListModal({ {title} - {subtitle && ( - {subtitle} - )} + {subtitle && {subtitle}} - + @@ -172,9 +161,7 @@ export function ActivityListModal({ {intensityZone && ( - - {intensityZone} - + {intensityZone} )} @@ -187,18 +174,17 @@ export function ActivityListModal({ Loading activities... ) : filteredActivities.length === 0 ? ( - - - - - - No Activities Found - - - {intensityZone - ? `No activities in the ${intensityZone} zone for this period` - : "No activities found for this date range"} - + + ) : ( @@ -210,9 +196,7 @@ export function ActivityListModal({ {filteredActivities.length} - - Activities - + Activities @@ -224,23 +208,16 @@ export function ActivityListModal({ ), )} - - Total TSS - + Total TSS {formatDuration( - filteredActivities.reduce( - (sum, a) => sum + (a.duration_seconds || 0), - 0, - ), + filteredActivities.reduce((sum, a) => sum + (a.duration_seconds || 0), 0), )} - - Total Time - + Total Time @@ -267,11 +244,7 @@ export function ActivityListModal({ - + @@ -309,9 +282,7 @@ export function ActivityListModal({ void; - onActivitySelect: ( - category: PublicActivityCategory, - gpsRecordingEnabled: boolean, - ) => void; + onActivitySelect: (category: PublicActivityCategory, gpsRecordingEnabled: boolean) => void; currentCategory: PublicActivityCategory; currentGpsRecordingEnabled: boolean; } @@ -147,12 +137,7 @@ export const ActivitySelectionModal = memo(function ActivitySelectionModal({ } return ( - + {/* Backdrop - tap to close */} @@ -171,66 +156,69 @@ export const ActivitySelectionModal = memo(function ActivitySelectionModal({ {/* GPS Toggle at Top */} - - handleGpsChange(true)} - className={`flex-1 py-3 rounded-lg items-center ${ - currentGpsRecordingEnabled - ? "bg-background shadow-sm" - : "" - }`} + + { + if (nextValue === "gps-on") { + handleGpsChange(true); + } else if (nextValue === "gps-off") { + handleGpsChange(false); + } + }} + className="w-full" > - - - - GPS ON - - - + + + + + GPS ON + + + - handleGpsChange(false)} - className={`flex-1 py-3 rounded-lg items-center ${ - !currentGpsRecordingEnabled - ? "bg-background shadow-sm" - : "" - }`} - > - - - - GPS OFF - - - + + + + + GPS OFF + + + + @@ -244,37 +232,24 @@ export const ActivitySelectionModal = memo(function ActivitySelectionModal({ key={activity.category} onPress={() => handleCategorySelect(activity.category)} className={`flex-row items-center p-4 rounded-xl border-2 ${ - isSelected - ? "border-primary bg-primary/10" - : "border-border bg-card" + isSelected ? "border-primary bg-primary/10" : "border-border bg-card" }`} > - + - { - getActivityDisplayName( - activity.category, - true, - ).split(" ")[0] - } + {getActivityDisplayName(activity.category, true).split(" ")[0]} {isSelected && ( - - ✓ - + )} diff --git a/apps/mobile/components/ScheduleActivityModal.tsx b/apps/mobile/components/ScheduleActivityModal.tsx index 8dcd9bef..5d190584 100644 --- a/apps/mobile/components/ScheduleActivityModal.tsx +++ b/apps/mobile/components/ScheduleActivityModal.tsx @@ -72,39 +72,36 @@ * ``` */ -import { TimelineChart } from "@/components/ActivityPlan/TimelineChart"; +import { zodResolver } from "@hookform/resolvers/zod"; +import DateTimePicker from "@react-native-community/datetimepicker"; +import { plannedActivityScheduleFormSchema } from "@repo/core"; +import { AlertDescription, AlertTitle, Alert as UiAlert } from "@repo/ui/components/alert"; import { Button } from "@repo/ui/components/button"; import { Card, CardContent } from "@repo/ui/components/card"; import { Icon } from "@repo/ui/components/icon"; import { Text } from "@repo/ui/components/text"; import { Textarea } from "@repo/ui/components/textarea"; -import { refreshScheduleViews } from "@/lib/scheduling/refreshScheduleViews"; -import { trpc } from "@/lib/trpc"; -import { zodResolver } from "@hookform/resolvers/zod"; -import DateTimePicker from "@react-native-community/datetimepicker"; -import { plannedActivityScheduleFormSchema } from "@repo/core"; import { useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; -import { Calendar, Clock, TrendingUp, X } from "lucide-react-native"; +import { AlertCircle, Calendar, Clock, TrendingUp, X } from "lucide-react-native"; import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { ActivityIndicator, - Alert, Modal, + Alert as NativeAlert, Pressable, ScrollView, View, } from "react-native"; -import { ConstraintValidator } from "./training-plan/modals/components/ConstraintValidator"; import { z } from "zod"; +import { TimelineChart } from "@/components/ActivityPlan/TimelineChart"; +import { refreshScheduleViews } from "@/lib/scheduling/refreshScheduleViews"; +import { trpc } from "@/lib/trpc"; +import { ConstraintValidator } from "./training-plan/modals/components/ConstraintValidator"; -type PlannedActivityScheduleFormInput = z.input< - typeof plannedActivityScheduleFormSchema ->; -type PlannedActivityScheduleFormOutput = z.output< - typeof plannedActivityScheduleFormSchema ->; +type PlannedActivityScheduleFormInput = z.input; +type PlannedActivityScheduleFormOutput = z.output; interface ScheduleActivityModalProps { visible: boolean; @@ -146,19 +143,11 @@ function toPickerDate(value: string | null | undefined): Date { return new Date(); } - return new Date( - parsed.getFullYear(), - parsed.getMonth(), - parsed.getDate(), - 12, - 0, - 0, - 0, - ); + return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate(), 12, 0, 0, 0); } function alertSuccess(message: string) { - Alert.alert("Success", message); + NativeAlert.alert("Success", message); } export function ScheduleActivityModal({ @@ -194,11 +183,7 @@ export function ScheduleActivityModal({ watch, reset, formState: { errors }, - } = useForm< - PlannedActivityScheduleFormInput, - unknown, - PlannedActivityScheduleFormOutput - >({ + } = useForm({ resolver: zodResolver(plannedActivityScheduleFormSchema), defaultValues: { scheduled_date: preselectedDate || toDateOnlyString(new Date()), @@ -217,17 +202,13 @@ export function ScheduleActivityModal({ // Fetch existing activity if editing const { data: existingActivity, isLoading: loadingExistingActivity } = - trpc.events.getById.useQuery( - { id: eventId! }, - { enabled: isEditMode && visible }, - ); + trpc.events.getById.useQuery({ id: eventId! }, { enabled: isEditMode && visible }); // Fetch plan details (only if we have an ID, not a template) - const { data: planDetails, isLoading: loadingPlan } = - trpc.activityPlans.getById.useQuery( - { id: currentActivityPlanId }, - { enabled: !!currentActivityPlanId && visible && !isTemplate }, - ); + const { data: planDetails, isLoading: loadingPlan } = trpc.activityPlans.getById.useQuery( + { id: currentActivityPlanId }, + { enabled: !!currentActivityPlanId && visible && !isTemplate }, + ); // Use template if provided, otherwise use fetched plan const displayPlan = isTemplate ? activityPlan : planDetails; @@ -345,14 +326,9 @@ export function ScheduleActivityModal({ const isSubmitting = createMutation.isPending || updateMutation.isPending; const isLoading = (loadingPlan && !isTemplate) || loadingExistingActivity; - const isValidationPending = - !!trainingPlanId && validationLoading && !validation; + const isValidationPending = !!trainingPlanId && validationLoading && !validation; const canSchedule = - !isLoading && - displayPlan && - !!currentActivityPlanId && - !isValidationPending && - !isSubmitting; + !isLoading && displayPlan && !!currentActivityPlanId && !isValidationPending && !isSubmitting; return ( {!isLoading && displayPlan && ( - - {displayPlan.name} - + {displayPlan.name} )} - - {displayPlan.name} - + {displayPlan.name} {displayPlan.description && ( - + {displayPlan.description} )} @@ -426,11 +395,7 @@ export function ScheduleActivityModal({ {displayPlan.estimated_duration && ( - + {formatDuration(displayPlan.estimated_duration)} @@ -438,11 +403,7 @@ export function ScheduleActivityModal({ )} {displayPlan.estimated_tss && ( - + {Math.round(displayPlan.estimated_tss)} TSS @@ -469,19 +430,10 @@ export function ScheduleActivityModal({ {/* Date Picker */} - - Scheduled Date - - setShowDatePicker(true)} - disabled={isSubmitting} - > + Scheduled Date + setShowDatePicker(true)} disabled={isSubmitting}> - + {format(scheduledDate, "EEEE, MMMM d, yyyy")} @@ -523,9 +475,7 @@ export function ScheduleActivityModal({ {/* Notes */} - - Notes (optional) - + Notes (optional) - - This activity cannot be scheduled yet - - - Duplicate the activity plan first, then schedule it from - its detail screen. - - + + This activity cannot be scheduled yet + + Duplicate the activity plan first, then schedule it from its detail screen. + + ) : null} {(createMutation.error || updateMutation.error) && ( - - - Failed to {isEditMode ? "update" : "schedule"} activity - - - {(createMutation.error || updateMutation.error) - ?.message || "Please try again"} - - + + Failed to {isEditMode ? "update" : "schedule"} activity + + {(createMutation.error || updateMutation.error)?.message || + "Please try again"} + + )} ) : ( - - Failed to load activity details - + Failed to load activity details )} @@ -578,19 +521,10 @@ export function ScheduleActivityModal({ {/* Footer Actions */} - - diff --git a/apps/mobile/components/training-plan/QuickAdjustSheet.tsx b/apps/mobile/components/training-plan/QuickAdjustSheet.tsx index 79f29874..e3ee92ca 100644 --- a/apps/mobile/components/training-plan/QuickAdjustSheet.tsx +++ b/apps/mobile/components/training-plan/QuickAdjustSheet.tsx @@ -1,26 +1,20 @@ import { Button } from "@repo/ui/components/button"; import { Icon } from "@repo/ui/components/icon"; +import { Separator } from "@repo/ui/components/separator"; import { Text } from "@repo/ui/components/text"; +import { useRouter } from "expo-router"; +import { Settings2, Sparkles, X } from "lucide-react-native"; +import React, { useState } from "react"; +import { ActivityIndicator, Alert, Modal, ScrollView, TouchableOpacity, View } from "react-native"; +import { ROUTES } from "@/lib/constants/routes"; import { useReliableMutation } from "@/lib/hooks/useReliableMutation"; +import { SmartSuggestion } from "@/lib/hooks/useSmartSuggestions"; import { trpc } from "@/lib/trpc"; import { ADJUSTMENT_PRESETS, AdjustmentType, getAdjustmentSummary, } from "@/lib/utils/training-adjustments"; -import { SmartSuggestion } from "@/lib/hooks/useSmartSuggestions"; -import { ROUTES } from "@/lib/constants/routes"; -import { useRouter } from "expo-router"; -import { Settings2, Sparkles, X } from "lucide-react-native"; -import React, { useState } from "react"; -import { - ActivityIndicator, - Alert, - Modal, - ScrollView, - TouchableOpacity, - View, -} from "react-native"; interface QuickAdjustSheetProps { visible: boolean; @@ -37,34 +31,24 @@ export function QuickAdjustSheet({ }: QuickAdjustSheetProps) { const router = useRouter(); const utils = trpc.useUtils(); - const [selectedAdjustment, setSelectedAdjustment] = - useState(null); - - const applyAdjustmentMutation = useReliableMutation( - trpc.trainingPlans.applyQuickAdjustment, - { - invalidate: [utils.trainingPlans], - onSuccess: () => { - Alert.alert("Success", "Training plan adjusted successfully"); - onClose(); - setSelectedAdjustment(null); - }, - onError: (error) => { - Alert.alert( - "Adjustment Failed", - error.message || "Failed to adjust plan", - ); - }, + const [selectedAdjustment, setSelectedAdjustment] = useState(null); + + const applyAdjustmentMutation = useReliableMutation(trpc.trainingPlans.applyQuickAdjustment, { + invalidate: [utils.trainingPlans], + onSuccess: () => { + Alert.alert("Success", "Training plan adjusted successfully"); + onClose(); + setSelectedAdjustment(null); }, - ); + onError: (error) => { + Alert.alert("Adjustment Failed", error.message || "Failed to adjust plan"); + }, + }); const handleApplySuggestion = () => { if (!smartSuggestion || !plan) return; - const changes = getAdjustmentSummary( - plan.structure, - smartSuggestion.adjustedStructure, - ); + const changes = getAdjustmentSummary(plan.structure, smartSuggestion.adjustedStructure); Alert.alert( "Apply Smart Suggestion?", @@ -120,12 +104,7 @@ export function QuickAdjustSheet({ }; return ( - + - - - Smart Suggestion - + + Smart Suggestion - - {smartSuggestion.title} - + {smartSuggestion.title} {smartSuggestion.description} @@ -192,11 +163,11 @@ export function QuickAdjustSheet({ {/* Divider */} - + Or choose adjustment type - + )} @@ -204,9 +175,7 @@ export function QuickAdjustSheet({ {/* Quick Adjustment Presets */} {!smartSuggestion && ( - - Quick Adjustments - + Quick Adjustments )} @@ -222,12 +191,8 @@ export function QuickAdjustSheet({ {preset.icon} - - {preset.label} - - - {preset.description} - + {preset.label} + {preset.description} ))} @@ -241,14 +206,8 @@ export function QuickAdjustSheet({ className="flex-row items-center justify-center p-4 border border-border rounded-lg active:bg-muted" activeOpacity={0.7} > - - - Custom Adjustment - + + Custom Adjustment diff --git a/apps/mobile/components/training-plan/WeeklyProgressCard.tsx b/apps/mobile/components/training-plan/WeeklyProgressCard.tsx index 984b3900..66cf7aa2 100644 --- a/apps/mobile/components/training-plan/WeeklyProgressCard.tsx +++ b/apps/mobile/components/training-plan/WeeklyProgressCard.tsx @@ -1,5 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/components/card"; import { Icon } from "@repo/ui/components/icon"; +import { Progress } from "@repo/ui/components/progress"; +import { Separator } from "@repo/ui/components/separator"; import { Text } from "@repo/ui/components/text"; import { CheckCircle2, Target } from "lucide-react-native"; import { View } from "react-native"; @@ -22,9 +24,7 @@ export function WeeklyProgressCard({ // Calculate progress percentages const tssProgress = targetTSS > 0 ? (completedTSS / targetTSS) * 100 : 0; const activityProgress = - totalPlannedActivities > 0 - ? (completedActivities / totalPlannedActivities) * 100 - : 0; + totalPlannedActivities > 0 ? (completedActivities / totalPlannedActivities) * 100 : 0; // Determine progress bar color based on completion const getTSSProgressColor = () => { @@ -55,24 +55,19 @@ export function WeeklyProgressCard({ Training Stress Score - - {Math.round(tssProgress)}% - + {Math.round(tssProgress)}% {/* TSS Progress Bar */} - - - + {/* TSS Numbers */} - - {Math.round(completedTSS)} - + {Math.round(completedTSS)} / {Math.round(targetTSS)} TSS target @@ -90,7 +85,7 @@ export function WeeklyProgressCard({ )} - + {/* Activity Completion */} @@ -105,12 +100,11 @@ export function WeeklyProgressCard({ {/* Activity Progress Bar */} - - - + {/* Activity Count */} @@ -133,8 +127,7 @@ export function WeeklyProgressCard({ {tssProgress < 50 && totalPlannedActivities > completedActivities && ( - ⚠️ Behind on weekly target. Consider completing scheduled - activities. + ⚠️ Behind on weekly target. Consider completing scheduled activities. )} diff --git a/apps/mobile/components/training-plan/create/SinglePageForm.tsx b/apps/mobile/components/training-plan/create/SinglePageForm.tsx index 246c8032..06e1c0b6 100644 --- a/apps/mobile/components/training-plan/create/SinglePageForm.tsx +++ b/apps/mobile/components/training-plan/create/SinglePageForm.tsx @@ -1,8 +1,25 @@ +import type { + CreationAvailabilityConfig, + CreationBehaviorControlsV1, + CreationConfigLocks, + CreationConstraints, + CreationContextSummary, + CreationFeasibilitySafetySummary, + CreationProvenance, + CreationRecentInfluenceAction, + CreationValueSource, + NoHistoryProjectionMetadata, + ProjectionChartPayload, + ReadinessDeltaDiagnostics, + TrainingPlanCalibrationConfig, +} from "@repo/core"; import { Badge } from "@repo/ui/components/badge"; +import { BoundedNumberInput } from "@repo/ui/components/bounded-number-input"; import { Button } from "@repo/ui/components/button"; +import { DurationInput } from "@repo/ui/components/duration-input"; import { Input } from "@repo/ui/components/input"; import { Label } from "@repo/ui/components/label"; -import { Textarea } from "@repo/ui/components/textarea"; +import { PaceInput } from "@repo/ui/components/pace-input"; import { Select, SelectContent, @@ -12,12 +29,7 @@ import { } from "@repo/ui/components/select"; import { Switch } from "@repo/ui/components/switch"; import { Text } from "@repo/ui/components/text"; -import { BoundedNumberInput } from "./inputs/BoundedNumberInput"; -import { DateField } from "./inputs/DateField"; -import { DurationInput } from "./inputs/DurationInput"; -import { NumberSliderInput } from "./inputs/NumberSliderInput"; -import { PaceInput } from "./inputs/PaceInput"; -import { PercentSliderInput } from "./inputs/PercentSliderInput"; +import { Textarea } from "@repo/ui/components/textarea"; import { Flag, Gauge, @@ -25,38 +37,20 @@ import { Pencil, Plus, ShieldAlert, - Trophy, Trash2, + Trophy, Zap, } from "lucide-react-native"; import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { - Modal, - Pressable, - ScrollView, - View, - useWindowDimensions, -} from "react-native"; +import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; import Svg, { Circle } from "react-native-svg"; -import { CreationProjectionChart } from "./CreationProjectionChart"; import { type CompositeWeightLocks } from "../../../lib/training-plan-form/calibration"; import { parseNumberOrUndefined } from "../../../lib/training-plan-form/input-parsers"; import type { BlockingIssue } from "../../../lib/training-plan-form/validation"; -import type { - CreationAvailabilityConfig, - CreationConfigLocks, - CreationConstraints, - CreationBehaviorControlsV1, - CreationContextSummary, - CreationFeasibilitySafetySummary, - NoHistoryProjectionMetadata, - ProjectionChartPayload, - ReadinessDeltaDiagnostics, - CreationProvenance, - CreationRecentInfluenceAction, - TrainingPlanCalibrationConfig, - CreationValueSource, -} from "@repo/core"; +import { CreationProjectionChart } from "./CreationProjectionChart"; +import { DateField } from "./inputs/DateField"; +import { NumberSliderInput } from "./inputs/NumberSliderInput"; +import { PercentSliderInput } from "./inputs/PercentSliderInput"; export type GoalTargetType = | "race_performance" @@ -149,13 +143,7 @@ interface EditingTargetRef { targetId: string; } -type FormTabKey = - | "plan" - | "goals" - | "availability" - | "constraints" - | "calibration" - | "review"; +type FormTabKey = "plan" | "goals" | "availability" | "constraints" | "calibration" | "review"; const allFormTabs: { key: FormTabKey; label: string }[] = [ { key: "plan", label: "Plan" }, @@ -168,8 +156,7 @@ const allFormTabs: { key: FormTabKey; label: string }[] = [ type TabIssueCounts = Record; -const createLocalId = () => - `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const createLocalId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const createEmptyTarget = (): GoalTargetFormData => ({ id: createLocalId(), @@ -275,12 +262,10 @@ const tabPanelClass = "gap-3 rounded-lg border border-border bg-card p-3"; const sectionCardClass = "gap-2"; const helperTextClass = "text-xs text-muted-foreground"; -const getWeekDayLabel = (day: string) => - day.slice(0, 1).toUpperCase() + day.slice(1, 3); +const getWeekDayLabel = (day: string) => day.slice(0, 1).toUpperCase() + day.slice(1, 3); -const getActivityCategoryLabel = ( - category?: GoalTargetFormData["activityCategory"], -) => activityCategoryOptions.find((option) => option.value === category)?.label; +const getActivityCategoryLabel = (category?: GoalTargetFormData["activityCategory"]) => + activityCategoryOptions.find((option) => option.value === category)?.label; const getTargetTypeLabel = (targetType: GoalTargetType) => { return targetTypeOptions.find((option) => option.value === targetType)?.label; @@ -339,12 +324,7 @@ const getTargetSummary = (target: GoalTargetFormData) => { }; const formatFeasibilityBandLabel = ( - band: - | "feasible" - | "stretch" - | "aggressive" - | "nearly_impossible" - | "infeasible", + band: "feasible" | "stretch" | "aggressive" | "nearly_impossible" | "infeasible", ) => { if (band === "feasible") return "On track"; if (band === "stretch") return "Challenging"; @@ -377,8 +357,7 @@ const formatDriverLabel = (driver: string) => { return driver.replaceAll("_", " "); }; -const formatNoteLabel = (note: string) => - note.replaceAll("_", " ").replaceAll("-", " "); +const formatNoteLabel = (note: string) => note.replaceAll("_", " ").replaceAll("-", " "); const toRecord = (value: unknown): Record | undefined => { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -464,9 +443,7 @@ const resolveGoalAssessmentConfidenceHint = ( ); }, ) - .filter( - (value: number | undefined): value is number => value !== undefined, - ), + .filter((value: number | undefined): value is number => value !== undefined), ); if (targetUncertainty !== undefined) { return `Uncertainty hint: forecast spread ${Math.round(targetUncertainty)}%.`; @@ -509,10 +486,7 @@ const readStringArray = (value: unknown): string[] => { return value.filter((item): item is string => typeof item === "string"); }; -const formatNumericDiagnostics = ( - record: Record | undefined, - limit: number, -) => { +const formatNumericDiagnostics = (record: Record | undefined, limit: number) => { if (!record) { return ""; } @@ -559,8 +533,7 @@ const resolveProjectionReviewDiagnostics = ( toRecord(scoped?.effective_optimizer_config) ?? toRecord(scoped?.effectiveOptimizerConfig); const objectiveContributions = - toRecord(scoped?.objective_contributions) ?? - toRecord(scoped?.objectiveContributions); + toRecord(scoped?.objective_contributions) ?? toRecord(scoped?.objectiveContributions); const objectiveComposition = toRecord(scoped?.objective_composition) ?? toRecord(scoped?.objectiveComposition) ?? @@ -568,16 +541,13 @@ const resolveProjectionReviewDiagnostics = ( objectiveContributions; const activeConstraintsRaw = readStringArray(scoped?.active_constraints); const bindingConstraintsRaw = readStringArray(scoped?.binding_constraints); - const clampCounts = - toRecord(scoped?.clamp_counts) ?? toRecord(scoped?.clampCounts); + const clampCounts = toRecord(scoped?.clamp_counts) ?? toRecord(scoped?.clampCounts); const sampledWeeks = readNumber(objectiveContributions?.sampled_weeks) ?? readNumber(objectiveContributions?.sampledWeeks); const derivedClampPressure = clampCounts && sampledWeeks && sampledWeeks > 0 - ? ((readNumber(clampCounts.tss) ?? 0) + - (readNumber(clampCounts.ctl) ?? 0)) / - sampledWeeks + ? ((readNumber(clampCounts.tss) ?? 0) + (readNumber(clampCounts.ctl) ?? 0)) / sampledWeeks : undefined; return { @@ -748,9 +718,7 @@ function GoalReadinessRing(props: { score: number; goalTitle: string }) { /> - - {Math.round(normalizedScore)} - + {Math.round(normalizedScore)} ); @@ -804,8 +772,7 @@ export function SinglePageForm({ const [activeGoalId, setActiveGoalId] = useState( () => formData.goals[0]?.id ?? null, ); - const [editingTargetRef, setEditingTargetRef] = - useState(null); + const [editingTargetRef, setEditingTargetRef] = useState(null); const visibleTabKeys = useMemo( () => showCreationConfig @@ -890,16 +857,14 @@ export function SinglePageForm({ ).length; const noHistoryMetadata = projectionChart?.no_history; const noHistoryReasons = noHistoryMetadata?.fitness_inference_reasons ?? []; - const projectionStartingState = - projectionChart?.constraint_summary?.starting_state; + const projectionStartingState = projectionChart?.constraint_summary?.starting_state; const noHistoryConfidenceLabel = noHistoryMetadata ? toNoHistoryConfidenceLabel(noHistoryMetadata.projection_floor_confidence) : "n/a"; - const noHistoryFloorAppliedLabel = noHistoryMetadata?.projection_floor_applied + const noHistoryFloorAppliedLabel = noHistoryMetadata?.projection_floor_applied ? "Yes" : "No"; + const noHistoryAvailabilityClampLabel = noHistoryMetadata?.floor_clamped_by_availability ? "Yes" : "No"; - const noHistoryAvailabilityClampLabel = - noHistoryMetadata?.floor_clamped_by_availability ? "Yes" : "No"; const noHistoryFitnessSignal = typeof noHistoryMetadata?.fitness_signal_0_1 === "number" ? Math.round(noHistoryMetadata.fitness_signal_0_1 * 100) @@ -909,9 +874,7 @@ export function SinglePageForm({ ? Math.round(noHistoryMetadata.goal_demand_score_0_1 * 100) : null; const projectionRiskScore = - typeof projectionChart?.risk_score === "number" - ? Math.round(projectionChart.risk_score) - : null; + typeof projectionChart?.risk_score === "number" ? Math.round(projectionChart.risk_score) : null; const noHistoryAccessibilitySummary = noHistoryMetadata ? `No-history cues. Confidence ${noHistoryConfidenceLabel}. Floor applied ${noHistoryFloorAppliedLabel}. Availability clamp ${noHistoryAvailabilityClampLabel}.${noHistoryFitnessSignal !== null ? ` Fitness signal ${noHistoryFitnessSignal} percent.` : ""}${noHistoryDemandScore !== null ? ` Goal demand ${noHistoryDemandScore} percent.` : ""}${projectionRiskScore !== null ? ` Risk score ${projectionRiskScore} percent.` : ""}` : undefined; @@ -936,12 +899,8 @@ export function SinglePageForm({ return; } - const goal = formData.goals.find( - (item) => item.id === editingTargetRef.goalId, - ); - const target = goal?.targets.find( - (item) => item.id === editingTargetRef.targetId, - ); + const goal = formData.goals.find((item) => item.id === editingTargetRef.goalId); + const target = goal?.targets.find((item) => item.id === editingTargetRef.targetId); if (!goal || !target) { setEditingTargetRef(null); } @@ -952,17 +911,13 @@ export function SinglePageForm({ return null; } - const goalIndex = formData.goals.findIndex( - (goal) => goal.id === editingTargetRef.goalId, - ); + const goalIndex = formData.goals.findIndex((goal) => goal.id === editingTargetRef.goalId); if (goalIndex < 0) { return null; } const goal = formData.goals[goalIndex]; - const targetIndex = goal.targets.findIndex( - (target) => target.id === editingTargetRef.targetId, - ); + const targetIndex = goal.targets.findIndex((target) => target.id === editingTargetRef.targetId); if (targetIndex < 0) { return null; } @@ -978,17 +933,11 @@ export function SinglePageForm({ const updateGoal = (goalId: string, updates: Partial) => { onFormDataChange({ ...formData, - goals: formData.goals.map((goal) => - goal.id === goalId ? { ...goal, ...updates } : goal, - ), + goals: formData.goals.map((goal) => (goal.id === goalId ? { ...goal, ...updates } : goal)), }); }; - const updateTarget = ( - goalId: string, - targetId: string, - updates: Partial, - ) => { + const updateTarget = (goalId: string, targetId: string, updates: Partial) => { onFormDataChange({ ...formData, goals: formData.goals.map((goal) => { @@ -1008,9 +957,7 @@ export function SinglePageForm({ const addGoal = () => { const referenceTargetDate = - formData.goals[0]?.targetDate ?? - new Date().toISOString().split("T")[0] ?? - ""; + formData.goals[0]?.targetDate ?? new Date().toISOString().split("T")[0] ?? ""; const newGoalIndex = formData.goals.length + 1; const newGoal = { @@ -1040,9 +987,7 @@ export function SinglePageForm({ onFormDataChange({ ...formData, goals: formData.goals.map((goal) => - goal.id === goalId - ? { ...goal, targets: [...goal.targets, target] } - : goal, + goal.id === goalId ? { ...goal, targets: [...goal.targets, target] } : goal, ), }); setEditingTargetRef({ goalId, targetId: target.id }); @@ -1114,9 +1059,7 @@ export function SinglePageForm({ const closeTargetEditor = () => setEditingTargetRef(null); const activeGoal = useMemo( - () => - formData.goals.find((goal) => goal.id === activeGoalId) ?? - formData.goals[0], + () => formData.goals.find((goal) => goal.id === activeGoalId) ?? formData.goals[0], [activeGoalId, formData.goals], ); const activeGoalIndex = activeGoal @@ -1178,13 +1121,7 @@ export function SinglePageForm({ projectionReviewDiagnostics.clampPressure !== undefined || projectionReviewDiagnostics.curvatureContribution !== undefined; const goalMarkersById = useMemo( - () => - new Map( - (projectionChart?.goal_markers ?? []).map((marker) => [ - marker.id, - marker, - ]), - ), + () => new Map((projectionChart?.goal_markers ?? []).map((marker) => [marker.id, marker])), [projectionChart?.goal_markers], ); @@ -1253,10 +1190,7 @@ export function SinglePageForm({ ) : null} - + {activeTab === "plan" && ( Plan details @@ -1288,9 +1222,7 @@ export function SinglePageForm({ } /> {resolvedPlanMetadata.name.trim().length === 0 ? ( - - Plan name is required. - + Plan name is required. ) : null} @@ -1313,8 +1245,7 @@ export function SinglePageForm({ /> - Training plan activation is controlled when you apply a template - to your schedule. + Training plan activation is controlled when you apply a template to your schedule. )} @@ -1337,28 +1268,18 @@ export function SinglePageForm({ {contextSummary && ( - Consistency:{" "} - {formatMarkerLabel( - contextSummary.recent_consistency_marker, - )} + Consistency: {formatMarkerLabel(contextSummary.recent_consistency_marker)} Effort confidence:{" "} - {formatMarkerLabel( - contextSummary.effort_confidence_marker, - )} + {formatMarkerLabel(contextSummary.effort_confidence_marker)} Profile completeness:{" "} - {formatMarkerLabel( - contextSummary.profile_metric_completeness_marker, - )} + {formatMarkerLabel(contextSummary.profile_metric_completeness_marker)} {contextSummary.rationale_codes.slice(0, 4).map((code) => ( - + - {formatCodeAsSentence(code)} ))} @@ -1411,11 +1332,7 @@ export function SinglePageForm({ Availability - @@ -1446,9 +1363,8 @@ export function SinglePageForm({ {weekDays.map((day) => { const dayConfig = - configData.availabilityConfig.days.find( - (item) => item.day === day, - ) ?? configData.availabilityConfig.days[0]; + configData.availabilityConfig.days.find((item) => item.day === day) ?? + configData.availabilityConfig.days[0]; if (!dayConfig) { return null; } @@ -1464,22 +1380,21 @@ export function SinglePageForm({ draft.availabilityConfig = { ...draft.availabilityConfig, template: "custom", - days: draft.availabilityConfig.days.map( - (candidate) => - candidate.day === day - ? { - ...candidate, - windows: isAvailable - ? [] - : [ - { - start_minute_of_day: 360, - end_minute_of_day: 450, - }, - ], - max_sessions: isAvailable ? 0 : 1, - } - : candidate, + days: draft.availabilityConfig.days.map((candidate) => + candidate.day === day + ? { + ...candidate, + windows: isAvailable + ? [] + : [ + { + start_minute_of_day: 360, + end_minute_of_day: 450, + }, + ], + max_sessions: isAvailable ? 0 : 1, + } + : candidate, ), }; draft.availabilityProvenance = { @@ -1503,11 +1418,7 @@ export function SinglePageForm({ Limits - @@ -1516,9 +1427,7 @@ export function SinglePageForm({ id="starting-ctl-assumption" label="Initial CTL (fitness)" value={ - configData.startingCtlAssumption ?? - projectionStartingState?.starting_ctl ?? - 0 + configData.startingCtlAssumption ?? projectionStartingState?.starting_ctl ?? 0 } min={0} max={250} @@ -1566,11 +1475,7 @@ export function SinglePageForm({ Tuning - @@ -1602,9 +1507,7 @@ export function SinglePageForm({ helperText="Higher values allow more week-to-week variation." onChange={(percent) => { updateConfig((draft) => { - draft.behaviorControlsV1.variability = Number( - (percent / 100).toFixed(2), - ); + draft.behaviorControlsV1.variability = Number((percent / 100).toFixed(2)); }); }} showNumericInput={false} @@ -1637,9 +1540,7 @@ export function SinglePageForm({ helperText="Negative values bias early load, positive values bias later load." onChange={(value) => { updateConfig((draft) => { - draft.behaviorControlsV1.shape_target = Number( - value.toFixed(2), - ); + draft.behaviorControlsV1.shape_target = Number(value.toFixed(2)); }); }} /> @@ -1663,9 +1564,7 @@ export function SinglePageForm({ { updateConfig((draft) => { - draft.behaviorControlsV1.starting_fitness_confidence = - Number((value / 100).toFixed(2)); + draft.behaviorControlsV1.starting_fitness_confidence = Number( + (value / 100).toFixed(2), + ); }); }} /> @@ -1715,38 +1612,30 @@ export function SinglePageForm({ accessibilityLabel={`${reviewNoticeCount} plan notice${reviewNoticeCount === 1 ? "" : "s"} to review`} > - - {reviewNoticeCount} - + {reviewNoticeCount} {isPreviewPending && ( - - Refreshing... - + Refreshing... )} - Review plan fit, risk, and trend changes before create. - Unresolved blocking issues prevent create unless you - explicitly acknowledge an override. + Review plan fit, risk, and trend changes before create. Unresolved blocking issues + prevent create unless you explicitly acknowledge an override. {feasibilitySafetySummary ? ( <> Plan fit:{" "} - {formatReviewBandLabel( - feasibilitySafetySummary.feasibility_band, - )} + {formatReviewBandLabel(feasibilitySafetySummary.feasibility_band)} - Risk:{" "} - {formatSafetyBandLabel( - feasibilitySafetySummary.safety_band, - )} + Risk: {formatSafetyBandLabel(feasibilitySafetySummary.safety_band)} - {feasibilitySafetySummary.top_drivers - .slice(0, 3) - .map((driver) => ( - - - {formatDriverText(driver.message, driver.code)} - - ))} + {feasibilitySafetySummary.top_drivers.slice(0, 3).map((driver) => ( + + - {formatDriverText(driver.message, driver.code)} + + ))} {hasProjectionReviewDiagnostics ? ( <> {projectionReviewDiagnostics.effectiveOptimizerSummary ? ( Effective optimizer:{" "} - { - projectionReviewDiagnostics.effectiveOptimizerSummary - } - . + {projectionReviewDiagnostics.effectiveOptimizerSummary}. ) : null} {projectionReviewDiagnostics.activeConstraints ? ( - Active constraints:{" "} - {projectionReviewDiagnostics.activeConstraints}. + Active constraints: {projectionReviewDiagnostics.activeConstraints}. ) : null} {projectionReviewDiagnostics.bindingConstraints || - projectionReviewDiagnostics.clampPressure !== - undefined ? ( + projectionReviewDiagnostics.clampPressure !== undefined ? ( Binding constraints:{" "} - {projectionReviewDiagnostics.bindingConstraints || - "none"} - {projectionReviewDiagnostics.clampPressure !== - undefined + {projectionReviewDiagnostics.bindingConstraints || "none"} + {projectionReviewDiagnostics.clampPressure !== undefined ? ` | clamp pressure ${Math.round( Math.max( 0, - Math.min( - 100, - projectionReviewDiagnostics.clampPressure * - 100, - ), + Math.min(100, projectionReviewDiagnostics.clampPressure * 100), ), )}%` : ""} @@ -1818,39 +1688,32 @@ export function SinglePageForm({ ) : null} {projectionReviewDiagnostics.objectiveSummary ? ( - Objective mix:{" "} - {projectionReviewDiagnostics.objectiveSummary} - {projectionReviewDiagnostics.curvatureContribution !== - undefined + Objective mix: {projectionReviewDiagnostics.objectiveSummary} + {projectionReviewDiagnostics.curvatureContribution !== undefined ? ` | curvature ${projectionReviewDiagnostics.curvatureContribution.toFixed(2)}` : ""} . - ) : projectionReviewDiagnostics.curvatureContribution !== - undefined ? ( + ) : projectionReviewDiagnostics.curvatureContribution !== undefined ? ( Curvature contribution:{" "} - {projectionReviewDiagnostics.curvatureContribution.toFixed( - 2, - )} - . + {projectionReviewDiagnostics.curvatureContribution.toFixed(2)}. ) : null} ) : null} - The planner always prefers a safer progression that still - moves you toward your goals. + The planner always prefers a safer progression that still moves you toward + your goals. - If a blocking issue remains unresolved, create stays - disabled until you explicitly acknowledge an override. + If a blocking issue remains unresolved, create stays disabled until you + explicitly acknowledge an override. ) : ( - Your plan check appears here once enough setup details are - available. + Your plan check appears here once enough setup details are available. )} @@ -1858,86 +1721,43 @@ export function SinglePageForm({ {activeTab === "review" && readinessDeltaDiagnostics ? ( - - What changed most recently - + What changed most recently - Readiness{" "} - {formatDirectionLabel( - readinessDeltaDiagnostics.readiness.direction, - )}{" "} - by{" "} - {Math.abs(readinessDeltaDiagnostics.readiness.delta).toFixed( - 2, - )}{" "} - points ({" "} - {readinessDeltaDiagnostics.readiness.previous_score.toFixed( - 2, - )} + Readiness {formatDirectionLabel(readinessDeltaDiagnostics.readiness.direction)} by{" "} + {Math.abs(readinessDeltaDiagnostics.readiness.delta).toFixed(2)} points ({" "} + {readinessDeltaDiagnostics.readiness.previous_score.toFixed(2)} {" -> "} {readinessDeltaDiagnostics.readiness.current_score.toFixed(2)} ). - Main reason:{" "} - {formatDriverLabel(readinessDeltaDiagnostics.dominant_driver)} - . + Main reason: {formatDriverLabel(readinessDeltaDiagnostics.dominant_driver)}. Training load{" "} - {formatDirectionLabel( - readinessDeltaDiagnostics.impacts.load.direction, - )}{" "} - by{" "} - {Math.abs( - readinessDeltaDiagnostics.impacts.load.delta, - ).toFixed(2)}{" "} - ({" "} - {readinessDeltaDiagnostics.impacts.load.previous_value.toFixed( - 2, - )} + {formatDirectionLabel(readinessDeltaDiagnostics.impacts.load.direction)} by{" "} + {Math.abs(readinessDeltaDiagnostics.impacts.load.delta).toFixed(2)} ({" "} + {readinessDeltaDiagnostics.impacts.load.previous_value.toFixed(2)} {" -> "} - {readinessDeltaDiagnostics.impacts.load.current_value.toFixed( - 2, - )} + {readinessDeltaDiagnostics.impacts.load.current_value.toFixed(2)} ). Fatigue{" "} - {formatDirectionLabel( - readinessDeltaDiagnostics.impacts.fatigue.direction, - )}{" "} - by{" "} - {Math.abs( - readinessDeltaDiagnostics.impacts.fatigue.delta, - ).toFixed(2)}{" "} - ({" "} - {readinessDeltaDiagnostics.impacts.fatigue.previous_value.toFixed( - 2, - )} + {formatDirectionLabel(readinessDeltaDiagnostics.impacts.fatigue.direction)} by{" "} + {Math.abs(readinessDeltaDiagnostics.impacts.fatigue.delta).toFixed(2)} ({" "} + {readinessDeltaDiagnostics.impacts.fatigue.previous_value.toFixed(2)} {" -> "} - {readinessDeltaDiagnostics.impacts.fatigue.current_value.toFixed( - 2, - )} + {readinessDeltaDiagnostics.impacts.fatigue.current_value.toFixed(2)} ). Timeline pressure{" "} - {formatDirectionLabel( - readinessDeltaDiagnostics.impacts.feasibility.direction, - )}{" "} - by{" "} - {Math.abs( - readinessDeltaDiagnostics.impacts.feasibility.delta, - ).toFixed(2)}{" "} - ({" "} - {readinessDeltaDiagnostics.impacts.feasibility.previous_value.toFixed( - 2, - )} + {formatDirectionLabel(readinessDeltaDiagnostics.impacts.feasibility.direction)} by{" "} + {Math.abs(readinessDeltaDiagnostics.impacts.feasibility.delta).toFixed(2)} ({" "} + {readinessDeltaDiagnostics.impacts.feasibility.previous_value.toFixed(2)} {" -> "} - {readinessDeltaDiagnostics.impacts.feasibility.current_value.toFixed( - 2, - )} + {readinessDeltaDiagnostics.impacts.feasibility.current_value.toFixed(2)} ). @@ -1948,9 +1768,7 @@ export function SinglePageForm({ Goal-by-goal check {goalAssessments.map((assessment, index) => { const marker = goalMarkersById.get(assessment.goal_id); - const title = marker?.name?.trim() - ? marker.name - : `Goal ${index + 1}`; + const title = marker?.name?.trim() ? marker.name : `Goal ${index + 1}`; const fallbackReadinessScore = assessment.target_scores.length > 0 ? assessment.target_scores.reduce( @@ -1971,16 +1789,10 @@ export function SinglePageForm({ className="gap-2 rounded-md border border-border bg-muted/20 p-2.5" > - + - + {title} @@ -1992,25 +1804,17 @@ export function SinglePageForm({ {assessment.state_readiness_score !== undefined ? ( - State readiness:{" "} - {Math.round(assessment.state_readiness_score)} / - 100 + State readiness: {Math.round(assessment.state_readiness_score)} / 100 ) : null} - {assessment.goal_alignment_loss_0_100 !== - undefined ? ( + {assessment.goal_alignment_loss_0_100 !== undefined ? ( - Alignment loss:{" "} - {Math.round(assessment.goal_alignment_loss_0_100)}{" "} - / 100 + Alignment loss: {Math.round(assessment.goal_alignment_loss_0_100)} / + 100 ) : null} - - {formatFeasibilityBandLabel( - assessment.feasibility_band, - )} - + {formatFeasibilityBandLabel(assessment.feasibility_band)} @@ -2019,8 +1823,8 @@ export function SinglePageForm({ key={`${assessment.goal_id}-${target.kind}-${targetIndex}`} className="text-xs text-muted-foreground" > - {getAssessmentTargetKindLabel(target.kind)}{" "} - confidence: {Math.round(target.score_0_100)} / 100 + {getAssessmentTargetKindLabel(target.kind)} confidence:{" "} + {Math.round(target.score_0_100)} / 100 {target.unmet_gap !== undefined ? ` | shortfall ${Number(target.unmet_gap.toFixed(2))}` : ""} @@ -2049,22 +1853,17 @@ export function SinglePageForm({ - - Blocking issues - + Blocking issues - Resolve these issues, or acknowledge an override to allow - create. + Resolve these issues, or acknowledge an override to allow create. {blockingIssues.map((conflict) => ( - - {conflict.message} - + {conflict.message} ))} @@ -2074,8 +1873,7 @@ export function SinglePageForm({ Allow create despite blockers - I understand this create may violate safety or - feasibility guardrails. + I understand this create may violate safety or feasibility guardrails. Goals - - Add one or more goals and mix race, pace, power, or heart-rate - targets. + Add one or more goals and mix race, pace, power, or heart-rate targets. - {errors.goals ? ( - {errors.goals} - ) : null} + {errors.goals ? {errors.goals} : null} removeGoal(activeGoal.id)} - disabled={ - formData.goals.length <= 1 || activeGoalIndex === 0 - } + disabled={formData.goals.length <= 1 || activeGoalIndex === 0} accessibilityLabel="Delete goal" > @@ -2241,16 +2028,12 @@ export function SinglePageForm({ - - Targets - + Targets {rowError ? ( - - Adjust - + Adjust ) : null} - {rowError && ( - - {rowError} - - )} + {rowError && {rowError}} ); })} @@ -2397,10 +2154,7 @@ export function SinglePageForm({ {editingContext && ( - +